tftsr-devops_investigation/src-tauri/src/docs/postmortem.rs
Shaun Arman 79a623dbb2 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.
2026-04-19 18:13:30 -05:00

358 lines
13 KiB
Rust

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;
let mut md = String::new();
md.push_str(&format!(
"# Blameless Post-Mortem: {title}\n\n",
title = issue.title
));
// Header metadata
md.push_str("## Metadata\n\n");
md.push_str(&format!(
"- **Date:** {created_at}\n",
created_at = issue.created_at
));
md.push_str(&format!(
"- **Severity:** {severity}\n",
severity = issue.severity
));
md.push_str(&format!(
"- **Category:** {category}\n",
category = issue.category
));
md.push_str(&format!("- **Status:** {status}\n", status = issue.status));
md.push_str(&format!(
"- **Last Updated:** {updated_at}\n",
updated_at = issue.updated_at
));
md.push_str(&format!(
"- **Assigned To:** {}\n",
if issue.assigned_to.is_empty() {
"_Unassigned_"
} else {
&issue.assigned_to
}
));
md.push_str("- **Authors:** _[Add authors]_\n");
md.push_str("- **Reviewers:** _[Add reviewers]_\n\n");
// Executive Summary
md.push_str("## Executive Summary\n\n");
if !issue.description.is_empty() {
md.push_str(&issue.description);
md.push_str("\n\n");
} else {
md.push_str("_Provide a brief executive summary of the incident._\n\n");
}
// Impact
md.push_str("## Impact\n\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");
// Timeline
md.push_str("## Timeline\n\n");
md.push_str("| Time (UTC) | Event |\n");
md.push_str("|------------|-------|\n");
md.push_str(&format!(
"| {created_at} | Issue created |\n",
created_at = issue.created_at
));
if let Some(ref resolved) = issue.resolved_at {
md.push_str(&format!("| {resolved} | Issue resolved |\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");
if detail.resolution_steps.is_empty() {
md.push_str("### 5 Whys\n\n");
md.push_str("1. **Why?** _[First question]_ -> _[Answer]_\n");
md.push_str("2. **Why?** _[Second question]_ -> _[Answer]_\n");
md.push_str("3. **Why?** _[Third question]_ -> _[Answer]_\n");
md.push_str("4. **Why?** _[Fourth question]_ -> _[Answer]_\n");
md.push_str("5. **Why?** _[Fifth question]_ -> _[Answer]_\n\n");
} else {
md.push_str("### 5 Whys\n\n");
for step in &detail.resolution_steps {
let answer = if step.answer.is_empty() {
"_Awaiting answer_"
} else {
&step.answer
};
md.push_str(&format!(
"{}. **Why?** {} -> {}\n",
step.step_order, step.why_question, answer
));
}
md.push('\n');
if let Some(last) = detail.resolution_steps.last() {
if !last.answer.is_empty() {
md.push_str(&format!(
"**Root Cause:** {answer}\n\n",
answer = last.answer
));
}
}
}
// Contributing Factors
md.push_str("## Contributing Factors\n\n");
md.push_str(
"_This is a blameless post-mortem. Focus on systems and processes, not individuals._\n\n",
);
md.push_str("- _[Factor 1: e.g., Insufficient monitoring coverage]_\n");
md.push_str("- _[Factor 2: e.g., Missing automated failover]_\n");
md.push_str("- _[Factor 3: e.g., Deployment timing during peak hours]_\n\n");
// 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");
// What Could Be Improved
md.push_str("## What Could Be Improved\n\n");
md.push_str("- _[e.g., Faster escalation path]_\n");
md.push_str("- _[e.g., Better runbook documentation]_\n");
md.push_str("- _[e.g., More comprehensive testing]_\n\n");
// Action Items
md.push_str("## Action Items\n\n");
md.push_str("| Priority | Action | Owner | Due Date | Status |\n");
md.push_str("|----------|--------|-------|----------|--------|\n");
md.push_str("| P0 | _[Critical fix]_ | _[Owner]_ | _[Date]_ | Open |\n");
md.push_str("| P1 | _[Prevention measure]_ | _[Owner]_ | _[Date]_ | Open |\n");
md.push_str("| P2 | _[Monitoring improvement]_ | _[Owner]_ | _[Date]_ | Open |\n\n");
// Log Files
if !detail.log_files.is_empty() {
md.push_str("## Appendix: Log Files\n\n");
for lf in &detail.log_files {
md.push_str(&format!(
"- `{}` ({} bytes, redacted: {})\n",
lf.file_name,
lf.file_size,
if lf.redacted { "yes" } else { "no" }
));
}
md.push('\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, ResolutionStep, TimelineEvent};
fn make_test_detail() -> IssueDetail {
IssueDetail {
issue: Issue {
id: "pm-456".to_string(),
title: "Payment processing outage".to_string(),
description: "Payment gateway returning 503 for all transactions.".to_string(),
severity: "critical".to_string(),
status: "resolved".to_string(),
category: "payments".to_string(),
source: "monitoring".to_string(),
created_at: "2025-02-10 08:00:00".to_string(),
updated_at: "2025-02-10 14:00:00".to_string(),
resolved_at: Some("2025-02-10 12:30:00".to_string()),
assigned_to: "payments-team".to_string(),
tags: "[]".to_string(),
},
log_files: vec![],
image_attachments: vec![],
resolution_steps: vec![ResolutionStep {
id: "rs-pm-1".to_string(),
issue_id: "pm-456".to_string(),
step_order: 1,
why_question: "Why did payments fail?".to_string(),
answer: "Gateway certificate expired.".to_string(),
evidence: "SSL handshake failure in logs.".to_string(),
created_at: "2025-02-10 09:00:00".to_string(),
}],
conversations: vec![],
timeline_events: vec![],
}
}
#[test]
fn test_postmortem_contains_blameless_title() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("# Blameless Post-Mortem: Payment processing outage"));
}
#[test]
fn test_postmortem_contains_metadata() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("- **Severity:** critical"));
assert!(md.contains("- **Category:** payments"));
assert!(md.contains("- **Assigned To:** payments-team"));
}
#[test]
fn test_postmortem_contains_executive_summary() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("Payment gateway returning 503"));
}
#[test]
fn test_postmortem_contains_timeline_with_resolved() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("2025-02-10 08:00:00"));
assert!(md.contains("2025-02-10 12:30:00"));
assert!(md.contains("Issue resolved"));
}
#[test]
fn test_postmortem_contains_five_whys() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("Why did payments fail?"));
assert!(md.contains("Gateway certificate expired."));
}
#[test]
fn test_postmortem_contains_blameless_reminder() {
let md = generate_postmortem_markdown(&make_test_detail());
assert!(md.contains("blameless post-mortem"));
}
#[test]
fn test_postmortem_empty_description_shows_placeholder() {
let mut detail = make_test_detail();
detail.issue.description = String::new();
let md = generate_postmortem_markdown(&detail);
assert!(md.contains("Provide a brief executive summary"));
}
#[test]
fn test_postmortem_action_items_table() {
let md = generate_postmortem_markdown(&make_test_detail());
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"));
}
}