feat: initial implementation of TFTSR IT Triage & RCA application
Implements Phases 1-8 of the TFTSR implementation plan.
Rust backend (Tauri 2.x, src-tauri/):
- Multi-provider AI: OpenAI-compatible, Anthropic, Gemini, Mistral, Ollama
- PII detection engine: 11 regex patterns with overlap resolution
- SQLCipher AES-256 encrypted database with 10 versioned migrations
- 28 Tauri IPC commands for triage, analysis, document, and system ops
- Ollama: hardware probe, model recommendations, pull/delete with events
- RCA and blameless post-mortem Markdown document generators
- PDF export via printpdf
- Audit log: SHA-256 hash of every external data send
- Integration stubs for Confluence, ServiceNow, Azure DevOps (v0.2)
Frontend (React 18 + TypeScript + Vite, src/):
- 9 pages: full triage workflow NewIssue→LogUpload→Triage→Resolution→RCA→Postmortem→History+Settings
- 7 components: ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI primitives
- 3 Zustand stores: session, settings (persisted), history
- Type-safe tauriCommands.ts matching Rust backend types exactly
- 8 IT domain system prompts (Linux, Windows, Network, K8s, DB, Virt, HW, Obs)
DevOps:
- .woodpecker/test.yml: rustfmt, clippy, cargo test, tsc, vitest on every push
- .woodpecker/release.yml: linux/amd64 + linux/arm64 builds, Gogs release upload
Verified:
- cargo check: zero errors
- tsc --noEmit: zero errors
- vitest run: 13/13 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 03:36:25 +00:00
|
|
|
use crate::db::models::IssueDetail;
|
|
|
|
|
|
|
|
|
|
pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
|
|
|
|
let issue = &detail.issue;
|
|
|
|
|
|
|
|
|
|
let mut md = String::new();
|
|
|
|
|
|
|
|
|
|
md.push_str(&format!("# Root Cause Analysis: {}\n\n", 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** | {} |\n", issue.id));
|
|
|
|
|
md.push_str(&format!("| **Category** | {} |\n", issue.category));
|
|
|
|
|
md.push_str(&format!("| **Status** | {} |\n", issue.status));
|
|
|
|
|
md.push_str(&format!("| **Severity** | {} |\n", issue.severity));
|
|
|
|
|
md.push_str(&format!("| **Source** | {} |\n", issue.source));
|
2026-03-15 17:43:46 +00:00
|
|
|
md.push_str(&format!(
|
|
|
|
|
"| **Assigned To** | {} |\n",
|
|
|
|
|
if issue.assigned_to.is_empty() {
|
|
|
|
|
"Unassigned"
|
|
|
|
|
} else {
|
|
|
|
|
&issue.assigned_to
|
|
|
|
|
}
|
|
|
|
|
));
|
feat: initial implementation of TFTSR IT Triage & RCA application
Implements Phases 1-8 of the TFTSR implementation plan.
Rust backend (Tauri 2.x, src-tauri/):
- Multi-provider AI: OpenAI-compatible, Anthropic, Gemini, Mistral, Ollama
- PII detection engine: 11 regex patterns with overlap resolution
- SQLCipher AES-256 encrypted database with 10 versioned migrations
- 28 Tauri IPC commands for triage, analysis, document, and system ops
- Ollama: hardware probe, model recommendations, pull/delete with events
- RCA and blameless post-mortem Markdown document generators
- PDF export via printpdf
- Audit log: SHA-256 hash of every external data send
- Integration stubs for Confluence, ServiceNow, Azure DevOps (v0.2)
Frontend (React 18 + TypeScript + Vite, src/):
- 9 pages: full triage workflow NewIssue→LogUpload→Triage→Resolution→RCA→Postmortem→History+Settings
- 7 components: ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI primitives
- 3 Zustand stores: session, settings (persisted), history
- Type-safe tauriCommands.ts matching Rust backend types exactly
- 8 IT domain system prompts (Linux, Windows, Network, K8s, DB, Virt, HW, Obs)
DevOps:
- .woodpecker/test.yml: rustfmt, clippy, cargo test, tsc, vitest on every push
- .woodpecker/release.yml: linux/amd64 + linux/arm64 builds, Gogs release upload
Verified:
- cargo check: zero errors
- tsc --noEmit: zero errors
- vitest run: 13/13 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 03:36:25 +00:00
|
|
|
md.push_str(&format!("| **Created** | {} |\n", issue.created_at));
|
|
|
|
|
md.push_str(&format!("| **Last Updated** | {} |\n", issue.updated_at));
|
|
|
|
|
if let Some(ref resolved) = issue.resolved_at {
|
|
|
|
|
md.push_str(&format!("| **Resolved** | {} |\n", resolved));
|
|
|
|
|
}
|
|
|
|
|
md.push_str("\n");
|
|
|
|
|
|
|
|
|
|
if !issue.description.is_empty() {
|
|
|
|
|
md.push_str("## Description\n\n");
|
|
|
|
|
md.push_str(&issue.description);
|
|
|
|
|
md.push_str("\n\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:** {}\n\n", step.answer));
|
|
|
|
|
} else {
|
|
|
|
|
md.push_str("_Awaiting answer._\n\n");
|
|
|
|
|
}
|
|
|
|
|
if !step.evidence.is_empty() {
|
|
|
|
|
md.push_str(&format!("**Evidence:** {}\n\n", 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_str("\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 TFTSR IT Triage 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};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}],
|
|
|
|
|
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![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|