Compare commits
No commits in common. "master" and "v0.2.66" have entirely different histories.
@ -43,13 +43,13 @@ jobs:
|
||||
git diff origin/${{ github.base_ref }}..HEAD > /tmp/pr_diff.txt
|
||||
echo "diff_size=$(wc -l < /tmp/pr_diff.txt | tr -d ' ')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Analyze with LLM
|
||||
- name: Analyze with Ollama
|
||||
id: analyze
|
||||
if: steps.diff.outputs.diff_size != '0'
|
||||
shell: bash
|
||||
env:
|
||||
LITELLM_URL: http://172.0.0.29:11434/v1
|
||||
LITELLM_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
OLLAMA_URL: https://ollama-ui.tftsr.com/ollama/v1
|
||||
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
@ -62,32 +62,32 @@ jobs:
|
||||
| grep -v -E '^[+-].*[A-Za-z0-9+/]{40,}={0,2}([^A-Za-z0-9+/=]|$)')
|
||||
PROMPT="Analyze the following code changes for correctness, security issues, and best practices. PR Title: ${PR_TITLE}\n\nDiff:\n${DIFF_CONTENT}\n\nProvide a review with: 1) Summary, 2) Bugs/errors, 3) Security issues, 4) Best practices. Give specific comments with suggested fixes."
|
||||
BODY=$(jq -cn \
|
||||
--arg model "qwen2.5-72b" \
|
||||
--arg model "qwen3-coder-next:latest" \
|
||||
--arg content "$PROMPT" \
|
||||
'{model: $model, messages: [{role: "user", content: $content}], stream: false}')
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API (${#BODY} bytes)..."
|
||||
HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \
|
||||
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
|
||||
-o /tmp/llm_response.json -w "%{http_code}" \
|
||||
-X POST "$LITELLM_URL/chat/completions" \
|
||||
-H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling Ollama API (${#BODY} bytes)..."
|
||||
HTTP_CODE=$(curl -s --max-time 120 --connect-timeout 30 \
|
||||
--retry 3 --retry-delay 5 --retry-connrefused --retry-max-time 120 \
|
||||
-o /tmp/ollama_response.json -w "%{http_code}" \
|
||||
-X POST "$OLLAMA_URL/chat/completions" \
|
||||
-H "Authorization: Bearer $OLLAMA_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY")
|
||||
echo "HTTP status: $HTTP_CODE"
|
||||
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
|
||||
echo "Response file size: $(wc -c < /tmp/ollama_response.json) bytes"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "ERROR: liteLLM returned HTTP $HTTP_CODE"
|
||||
cat /tmp/llm_response.json
|
||||
echo "ERROR: Ollama returned HTTP $HTTP_CODE"
|
||||
cat /tmp/ollama_response.json
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty /tmp/llm_response.json 2>/dev/null; then
|
||||
echo "ERROR: Invalid JSON response from liteLLM"
|
||||
cat /tmp/llm_response.json
|
||||
if ! jq empty /tmp/ollama_response.json 2>/dev/null; then
|
||||
echo "ERROR: Invalid JSON response from Ollama"
|
||||
cat /tmp/ollama_response.json
|
||||
exit 1
|
||||
fi
|
||||
REVIEW=$(jq -r '.choices[0].message.content // empty' /tmp/llm_response.json)
|
||||
REVIEW=$(jq -r '.choices[0].message.content // empty' /tmp/ollama_response.json)
|
||||
if [ -z "$REVIEW" ]; then
|
||||
echo "ERROR: No content in liteLLM response"
|
||||
echo "ERROR: No content in Ollama response"
|
||||
exit 1
|
||||
fi
|
||||
echo "Review length: ${#REVIEW} chars"
|
||||
@ -109,11 +109,11 @@ jobs:
|
||||
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
|
||||
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
|
||||
BODY=$(jq -n \
|
||||
--arg body "Automated PR Review (qwen2.5-72b via liteLLM):\n\n${REVIEW_BODY}\n\n---\n*automated code review*" \
|
||||
--arg body "🤖 Automated PR Review:\n\n${REVIEW_BODY}\n\n---\n*this is an automated review from Ollama*" \
|
||||
'{body: $body, event: "COMMENT"}')
|
||||
else
|
||||
BODY=$(jq -n \
|
||||
'{body: "Automated PR Review could not be completed - LLM analysis failed or produced no output.", event: "COMMENT"}')
|
||||
'{body: "⚠️ Automated PR Review could not be completed — Ollama analysis failed or produced no output.", event: "COMMENT"}')
|
||||
fi
|
||||
HTTP_CODE=$(curl -s --max-time 30 --connect-timeout 10 \
|
||||
-o /tmp/review_post_response.json -w "%{http_code}" \
|
||||
@ -131,4 +131,4 @@ jobs:
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash
|
||||
run: rm -f /tmp/pr_diff.txt /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json
|
||||
run: rm -f /tmp/pr_diff.txt /tmp/ollama_response.json /tmp/pr_review.txt /tmp/review_post_response.json
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@ -6,32 +6,11 @@ CI, chore, and build changes are excluded.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Bug Fixes
|
||||
- Harden timeline event input validation and atomic writes
|
||||
|
||||
### Documentation
|
||||
- Update wiki for timeline events and incident response methodology
|
||||
|
||||
### Features
|
||||
- Add timeline_events table, model, and CRUD commands
|
||||
- Populate RCA and postmortem docs with real timeline data
|
||||
- Wire incident response methodology into AI and record triage events
|
||||
|
||||
## [0.2.65] — 2026-04-15
|
||||
|
||||
### Bug Fixes
|
||||
- Add --locked to cargo commands and improve version update script
|
||||
- Remove invalid --locked flag from cargo commands and fix format string
|
||||
- **integrations**: Security and correctness improvements
|
||||
- Correct WIQL syntax and escape_wiql implementation
|
||||
|
||||
### Features
|
||||
- Implement dynamic versioning from Git tags
|
||||
- **integrations**: Implement query expansion for semantic search
|
||||
|
||||
### Security
|
||||
- Fix query expansion issues from PR review
|
||||
- Address all issues from automated PR review
|
||||
|
||||
## [0.2.63] — 2026-04-13
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ All command handlers receive `State<'_, AppState>` as a Tauri-injected parameter
|
||||
| `commands/integrations.rs` | Confluence / ServiceNow / ADO — v0.2 stubs |
|
||||
| `ai/provider.rs` | `Provider` trait + `create_provider()` factory |
|
||||
| `pii/detector.rs` | Multi-pattern PII scanner with overlap resolution |
|
||||
| `db/migrations.rs` | Versioned schema (17 migrations in `_migrations` table) |
|
||||
| `db/migrations.rs` | Versioned schema (12 migrations in `_migrations` table) |
|
||||
| `db/models.rs` | All DB types — see `IssueDetail` note below |
|
||||
| `docs/rca.rs` + `docs/postmortem.rs` | Markdown template builders |
|
||||
| `audit/log.rs` | `write_audit_event()` — called before every external send |
|
||||
@ -176,55 +176,6 @@ pub struct IssueDetail {
|
||||
|
||||
Use `detail.issue.title`, **not** `detail.title`.
|
||||
|
||||
## Incident Response Methodology
|
||||
|
||||
The application integrates a comprehensive incident response framework via system prompt injection. The `INCIDENT_RESPONSE_FRAMEWORK` constant in `src/lib/domainPrompts.ts` is appended to all 17 domain-specific system prompts (Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability, and others).
|
||||
|
||||
**5-Phase Framework:**
|
||||
|
||||
1. **Detection & Evidence Gathering** — Initial issue assessment, log collection, PII redaction
|
||||
2. **Diagnosis & Hypothesis Testing** — AI-assisted analysis, pattern matching against known incidents
|
||||
3. **Root Cause Analysis with 5-Whys** — Iterative questioning to identify underlying cause (steps 1–5)
|
||||
4. **Resolution & Prevention** — Remediation planning and implementation
|
||||
5. **Post-Incident Review** — Timeline-based blameless post-mortem and lessons learned
|
||||
|
||||
**System Prompt Injection:**
|
||||
|
||||
The `chat_message` command accepts an optional `system_prompt` parameter. If provided, it prepends domain expertise before the conversation history. If omitted, the framework selects the appropriate domain prompt based on the issue category. This allows:
|
||||
|
||||
- **Specialized expertise**: Different frameworks for Linux vs. Kubernetes vs. Network incidents
|
||||
- **Flexible override**: Users can inject custom system prompts for cross-domain problems
|
||||
- **Consistent methodology**: All 17 domain prompts follow the same 5-phase incident response structure
|
||||
|
||||
**Timeline Event Recording:**
|
||||
|
||||
Timeline events are recorded non-blockingly at key triage moments:
|
||||
|
||||
```
|
||||
Issue Creation → triage_started
|
||||
↓
|
||||
Log Upload → log_uploaded (metadata: file_name, file_size)
|
||||
↓
|
||||
Why-Level Progression → why_level_advanced (metadata: from_level → to_level)
|
||||
↓
|
||||
Root Cause Identified → root_cause_identified (metadata: root_cause, confidence)
|
||||
↓
|
||||
RCA Generated → rca_generated (metadata: doc_id, section_count)
|
||||
↓
|
||||
Postmortem Generated → postmortem_generated (metadata: doc_id, timeline_events_count)
|
||||
↓
|
||||
Document Exported → document_exported (metadata: format, file_path)
|
||||
```
|
||||
|
||||
**Document Generation:**
|
||||
|
||||
RCA and Postmortem generators now use real timeline event data instead of placeholders:
|
||||
|
||||
- **RCA**: Incorporates timeline to show detection-to-root-cause progression
|
||||
- **Postmortem**: Uses full timeline to demonstrate the complete incident lifecycle and response effectiveness
|
||||
|
||||
Timeline events are stored in the `timeline_events` table (indexed by issue_id and created_at for fast retrieval) and dual-written to `audit_log` for security/compliance purposes.
|
||||
|
||||
## Application Startup Sequence
|
||||
|
||||
```
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 17 versioned migrations are tracked in the `_migrations` table.
|
||||
TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 12 versioned migrations are tracked in the `_migrations` table.
|
||||
|
||||
**DB file location:** `{app_data_dir}/tftsr.db`
|
||||
|
||||
@ -38,7 +38,7 @@ pub fn init_db(data_dir: &Path) -> anyhow::Result<Connection> {
|
||||
|
||||
---
|
||||
|
||||
## Schema (17 Migrations)
|
||||
## Schema (11 Migrations)
|
||||
|
||||
### 001 — issues
|
||||
|
||||
@ -245,51 +245,6 @@ CREATE TABLE image_attachments (
|
||||
- Basic auth (ServiceNow): Store encrypted password
|
||||
- One credential per service (enforced by UNIQUE constraint)
|
||||
|
||||
### 017 — timeline_events (Incident Response Timeline)
|
||||
|
||||
```sql
|
||||
CREATE TABLE timeline_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
metadata TEXT, -- JSON object with event-specific data
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_timeline_events_issue ON timeline_events(issue_id);
|
||||
CREATE INDEX idx_timeline_events_time ON timeline_events(created_at);
|
||||
```
|
||||
|
||||
**Event Types:**
|
||||
- `triage_started` — Incident response begins, initial issue properties recorded
|
||||
- `log_uploaded` — Log file uploaded and analyzed
|
||||
- `why_level_advanced` — 5-Whys entry completed, progression to next level
|
||||
- `root_cause_identified` — Root cause determined from analysis
|
||||
- `rca_generated` — Root Cause Analysis document created
|
||||
- `postmortem_generated` — Post-mortem document created
|
||||
- `document_exported` — Document exported to file (MD or PDF)
|
||||
|
||||
**Metadata Structure (JSON):**
|
||||
```json
|
||||
{
|
||||
"triage_started": {"severity": "high", "category": "network"},
|
||||
"log_uploaded": {"file_name": "app.log", "file_size": 2048576},
|
||||
"why_level_advanced": {"from_level": 2, "to_level": 3, "question": "Why did the service timeout?"},
|
||||
"root_cause_identified": {"root_cause": "DNS resolution failure", "confidence": 0.95},
|
||||
"rca_generated": {"doc_id": "doc_abc123", "section_count": 7},
|
||||
"postmortem_generated": {"doc_id": "doc_def456", "timeline_events_count": 12},
|
||||
"document_exported": {"format": "pdf", "file_path": "/home/user/docs/rca.pdf"}
|
||||
}
|
||||
```
|
||||
|
||||
**Design Notes:**
|
||||
- Timeline events are **queryable** (indexed by issue_id and created_at) for document generation
|
||||
- Dual-write: Events recorded to both `timeline_events` and `audit_log` — timeline for chronological reporting, audit_log for security/compliance
|
||||
- `created_at`: TEXT UTC timestamp (`YYYY-MM-DD HH:MM:SS`)
|
||||
- Non-blocking writes: Timeline events recorded asynchronously at key triage moments
|
||||
- Cascade delete from issues ensures cleanup
|
||||
|
||||
---
|
||||
|
||||
## Key Design Notes
|
||||
@ -334,13 +289,4 @@ pub struct AuditEntry {
|
||||
pub user_id: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TimelineEvent {
|
||||
pub id: String,
|
||||
pub issue_id: String,
|
||||
pub event_type: String,
|
||||
pub description: String,
|
||||
pub metadata: Option<String>, // JSON
|
||||
pub created_at: String,
|
||||
}
|
||||
```
|
||||
|
||||
@ -62,27 +62,11 @@ updateFiveWhyCmd(entryId: string, answer: string) → void
|
||||
```
|
||||
Sets or updates the answer for an existing 5-Whys entry.
|
||||
|
||||
### `get_timeline_events`
|
||||
```typescript
|
||||
getTimelineEventsCmd(issueId: string) → TimelineEvent[]
|
||||
```
|
||||
Retrieves all timeline events for an issue, ordered by created_at ascending.
|
||||
```typescript
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
issue_id: string;
|
||||
event_type: string; // One of: triage_started, log_uploaded, why_level_advanced, etc.
|
||||
description: string;
|
||||
metadata?: Record<string, any>; // Event-specific JSON data
|
||||
created_at: string; // UTC timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### `add_timeline_event`
|
||||
```typescript
|
||||
addTimelineEventCmd(issueId: string, eventType: string, description: string, metadata?: Record<string, any>) → TimelineEvent
|
||||
addTimelineEventCmd(issueId: string, eventType: string, description: string) → TimelineEvent
|
||||
```
|
||||
Records a timestamped event in the issue timeline. Dual-writes to both `timeline_events` (for document generation) and `audit_log` (for security audit trail).
|
||||
Records a timestamped event in the issue timeline.
|
||||
|
||||
---
|
||||
|
||||
@ -153,9 +137,9 @@ Sends selected (redacted) log files to the AI provider with an analysis prompt.
|
||||
|
||||
### `chat_message`
|
||||
```typescript
|
||||
chatMessageCmd(issueId: string, message: string, providerConfig: ProviderConfig, systemPrompt?: string) → ChatResponse
|
||||
chatMessageCmd(issueId: string, message: string, providerConfig: ProviderConfig) → ChatResponse
|
||||
```
|
||||
Sends a message in the ongoing triage conversation. Optional `systemPrompt` parameter allows prepending domain expertise before conversation history. If not provided, the domain-specific system prompt for the issue category is injected automatically on first message. AI response is parsed for why-level indicators (1–5).
|
||||
Sends a message in the ongoing triage conversation. Domain system prompt is injected automatically on first message. AI response is parsed for why-level indicators (1–5).
|
||||
|
||||
### `list_providers`
|
||||
```typescript
|
||||
@ -171,13 +155,13 @@ Returns the list of supported providers with their available models and configur
|
||||
```typescript
|
||||
generateRcaCmd(issueId: string) → Document
|
||||
```
|
||||
Builds an RCA Markdown document from the issue data, 5-Whys answers, and timeline events. Uses real incident response timeline (log uploads, why-level progression, root cause identification) instead of placeholders.
|
||||
Builds an RCA Markdown document from the issue data, 5-Whys answers, and timeline.
|
||||
|
||||
### `generate_postmortem`
|
||||
```typescript
|
||||
generatePostmortemCmd(issueId: string) → Document
|
||||
```
|
||||
Builds a blameless post-mortem Markdown document. Incorporates timeline events to show the full incident lifecycle: detection, diagnosis, resolution, and post-incident review phases.
|
||||
Builds a blameless post-mortem Markdown document.
|
||||
|
||||
### `update_document`
|
||||
```typescript
|
||||
|
||||
@ -165,7 +165,6 @@ pub async fn chat_message(
|
||||
issue_id: String,
|
||||
message: String,
|
||||
provider_config: ProviderConfig,
|
||||
system_prompt: Option<String>,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChatResponse, String> {
|
||||
@ -233,21 +232,7 @@ pub async fn chat_message(
|
||||
// Search integration sources for relevant context
|
||||
let integration_context = search_integration_sources(&message, &app_handle, &state).await;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Inject domain system prompt if provided
|
||||
if let Some(ref prompt) = system_prompt {
|
||||
if !prompt.is_empty() {
|
||||
messages.push(Message {
|
||||
role: "system".into(),
|
||||
content: prompt.clone(),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.extend(history);
|
||||
let mut messages = history;
|
||||
|
||||
// If we found integration content, add it to the conversation context
|
||||
if !integration_context.is_empty() {
|
||||
|
||||
@ -2,7 +2,7 @@ use tauri::State;
|
||||
|
||||
use crate::db::models::{
|
||||
AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary,
|
||||
IssueUpdate, LogFile, ResolutionStep, TimelineEvent,
|
||||
IssueUpdate, LogFile, ResolutionStep,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@ -171,35 +171,12 @@ pub async fn get_issue(
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Load timeline events
|
||||
let mut te_stmt = db
|
||||
.prepare(
|
||||
"SELECT id, issue_id, event_type, description, metadata, created_at \
|
||||
FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let timeline_events: Vec<TimelineEvent> = te_stmt
|
||||
.query_map([&issue_id], |row| {
|
||||
Ok(TimelineEvent {
|
||||
id: row.get(0)?,
|
||||
issue_id: row.get(1)?,
|
||||
event_type: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
metadata: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(IssueDetail {
|
||||
issue,
|
||||
log_files,
|
||||
image_attachments,
|
||||
resolution_steps,
|
||||
conversations,
|
||||
timeline_events,
|
||||
})
|
||||
}
|
||||
|
||||
@ -325,11 +302,6 @@ pub async fn delete_issue(issue_id: String, state: State<'_, AppState>) -> Resul
|
||||
[&issue_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.execute(
|
||||
"DELETE FROM timeline_events WHERE issue_id = ?1",
|
||||
[&issue_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.execute("DELETE FROM issues WHERE id = ?1", [&issue_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@ -533,105 +505,37 @@ pub async fn update_five_why(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const VALID_EVENT_TYPES: &[&str] = &[
|
||||
"triage_started",
|
||||
"log_uploaded",
|
||||
"why_level_advanced",
|
||||
"root_cause_identified",
|
||||
"rca_generated",
|
||||
"postmortem_generated",
|
||||
"document_exported",
|
||||
];
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_timeline_event(
|
||||
issue_id: String,
|
||||
event_type: String,
|
||||
description: String,
|
||||
metadata: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TimelineEvent, String> {
|
||||
if !VALID_EVENT_TYPES.contains(&event_type.as_str()) {
|
||||
return Err(format!("Invalid event_type: {event_type}"));
|
||||
}
|
||||
|
||||
let meta = metadata.unwrap_or_else(|| "{}".to_string());
|
||||
if meta.len() > 10240 {
|
||||
return Err("metadata exceeds maximum size of 10KB".to_string());
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&meta)
|
||||
.map_err(|_| "metadata must be valid JSON".to_string())?;
|
||||
|
||||
let event = TimelineEvent::new(
|
||||
) -> Result<(), String> {
|
||||
// Use audit_log for timeline tracking
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let entry = crate::db::models::AuditEntry::new(
|
||||
event_type,
|
||||
"issue".to_string(),
|
||||
issue_id.clone(),
|
||||
event_type.clone(),
|
||||
description.clone(),
|
||||
meta,
|
||||
serde_json::json!({ "description": description }).to_string(),
|
||||
);
|
||||
|
||||
let mut db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let tx = db.transaction().map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
event.id,
|
||||
event.issue_id,
|
||||
event.event_type,
|
||||
event.description,
|
||||
event.metadata,
|
||||
event.created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
crate::audit::log::write_audit_event(
|
||||
&tx,
|
||||
&event_type,
|
||||
"issue",
|
||||
&issue_id,
|
||||
&serde_json::json!({ "description": description, "metadata": event.metadata }).to_string(),
|
||||
&db,
|
||||
&entry.action,
|
||||
&entry.entity_type,
|
||||
&entry.entity_id,
|
||||
&entry.details,
|
||||
)
|
||||
.map_err(|_| "Failed to write security audit entry".to_string())?;
|
||||
|
||||
// Update issue timestamp
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
tx.execute(
|
||||
db.execute(
|
||||
"UPDATE issues SET updated_at = ?1 WHERE id = ?2",
|
||||
rusqlite::params![now, issue_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_timeline_events(
|
||||
issue_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<TimelineEvent>, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, issue_id, event_type, description, metadata, created_at \
|
||||
FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let events = stmt
|
||||
.query_map([&issue_id], |row| {
|
||||
Ok(TimelineEvent {
|
||||
id: row.get(0)?,
|
||||
issue_id: row.get(1)?,
|
||||
event_type: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
metadata: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(events)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -199,20 +199,6 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
||||
"016_add_created_at",
|
||||
"ALTER TABLE ai_providers ADD COLUMN created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
|
||||
),
|
||||
(
|
||||
"017_create_timeline_events",
|
||||
"CREATE TABLE IF NOT EXISTS timeline_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
issue_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_timeline_events_issue ON timeline_events(issue_id);
|
||||
CREATE INDEX idx_timeline_events_time ON timeline_events(created_at);",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, sql) in migrations {
|
||||
@ -712,82 +698,4 @@ mod tests {
|
||||
// Should not fail even though columns already exist
|
||||
run_migrations(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_events_table_exists() {
|
||||
let conn = setup_test_db();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='timeline_events'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(timeline_events)").unwrap();
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert!(columns.contains(&"id".to_string()));
|
||||
assert!(columns.contains(&"issue_id".to_string()));
|
||||
assert!(columns.contains(&"event_type".to_string()));
|
||||
assert!(columns.contains(&"description".to_string()));
|
||||
assert!(columns.contains(&"metadata".to_string()));
|
||||
assert!(columns.contains(&"created_at".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_events_cascade_delete() {
|
||||
let conn = setup_test_db();
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["issue-1", "Test Issue", now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["te-1", "issue-1", "triage_started", "Started triage", "{}", "2025-01-15 10:00:00 UTC"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify event exists
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Delete issue — cascade should remove timeline event
|
||||
conn.execute("DELETE FROM issues WHERE id = 'issue-1'", [])
|
||||
.unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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'",
|
||||
)
|
||||
.unwrap();
|
||||
let indexes: Vec<String> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(indexes.contains(&"idx_timeline_events_issue".to_string()));
|
||||
assert!(indexes.contains(&"idx_timeline_events_time".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,6 @@ pub struct IssueDetail {
|
||||
pub image_attachments: Vec<ImageAttachment>,
|
||||
pub resolution_steps: Vec<ResolutionStep>,
|
||||
pub conversations: Vec<AiConversation>,
|
||||
pub timeline_events: Vec<TimelineEvent>,
|
||||
}
|
||||
|
||||
/// Lightweight row returned by list/search commands.
|
||||
@ -122,31 +121,9 @@ pub struct FiveWhyEntry {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimelineEvent {
|
||||
pub id: String,
|
||||
pub issue_id: String,
|
||||
pub event_type: String,
|
||||
pub description: String,
|
||||
pub metadata: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl TimelineEvent {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
// ─── Log File ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
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;
|
||||
@ -52,16 +51,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
|
||||
// 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("- **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");
|
||||
@ -77,19 +67,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
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');
|
||||
md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n\n");
|
||||
|
||||
// Root Cause Analysis
|
||||
md.push_str("## Root Cause Analysis\n\n");
|
||||
@ -136,19 +114,6 @@ 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");
|
||||
@ -193,7 +158,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::{Issue, IssueDetail, ResolutionStep, TimelineEvent};
|
||||
use crate::db::models::{Issue, IssueDetail, ResolutionStep};
|
||||
|
||||
fn make_test_detail() -> IssueDetail {
|
||||
IssueDetail {
|
||||
@ -223,7 +188,6 @@ mod tests {
|
||||
created_at: "2025-02-10 09:00:00".to_string(),
|
||||
}],
|
||||
conversations: vec![],
|
||||
timeline_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,76 +246,4 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +1,5 @@
|
||||
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;
|
||||
|
||||
@ -100,52 +57,6 @@ 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() {
|
||||
@ -232,7 +143,7 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep, TimelineEvent};
|
||||
use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep};
|
||||
|
||||
fn make_test_detail() -> IssueDetail {
|
||||
IssueDetail {
|
||||
@ -283,7 +194,6 @@ mod tests {
|
||||
},
|
||||
],
|
||||
conversations: vec![],
|
||||
timeline_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,135 +247,4 @@ 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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -629,10 +629,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_derive_aes_key_is_stable_for_same_input() {
|
||||
// Use deterministic helper to avoid env var race conditions in parallel tests
|
||||
let k1 = derive_aes_key_from_str("stable-test-key").unwrap();
|
||||
let k2 = derive_aes_key_from_str("stable-test-key").unwrap();
|
||||
std::env::set_var("TFTSR_ENCRYPTION_KEY", "stable-test-key");
|
||||
let k1 = derive_aes_key().unwrap();
|
||||
let k2 = derive_aes_key().unwrap();
|
||||
assert_eq!(k1, k2);
|
||||
std::env::remove_var("TFTSR_ENCRYPTION_KEY");
|
||||
}
|
||||
|
||||
// Test helper functions that accept key directly (bypass env var)
|
||||
|
||||
@ -69,7 +69,6 @@ pub fn run() {
|
||||
commands::db::add_five_why,
|
||||
commands::db::update_five_why,
|
||||
commands::db::add_timeline_event,
|
||||
commands::db::get_timeline_events,
|
||||
// Analysis / PII
|
||||
commands::analysis::upload_log_file,
|
||||
commands::analysis::upload_log_file_by_content,
|
||||
|
||||
@ -331,58 +331,6 @@ When analyzing identity and access issues, focus on these key areas:
|
||||
Always ask about the Keycloak version, realm configuration (external IdP vs local users vs LDAP), SSSD version and configured domains, and whether this is a first-time setup or a regression.`,
|
||||
};
|
||||
|
||||
export const INCIDENT_RESPONSE_FRAMEWORK = `
|
||||
|
||||
---
|
||||
|
||||
## INCIDENT RESPONSE METHODOLOGY
|
||||
|
||||
Follow this structured framework for every triage conversation. Each phase must be completed with evidence before advancing.
|
||||
|
||||
### Phase 1: Detection & Evidence Gathering
|
||||
- **Do NOT propose fixes** until the problem is fully understood
|
||||
- Gather: error messages, timestamps, affected systems, scope of impact, recent changes
|
||||
- Ask: "What changed? When did it start? Who/what is affected? What has been tried?"
|
||||
- Record all evidence with UTC timestamps
|
||||
- Establish a clear problem statement before proceeding
|
||||
|
||||
### Phase 2: Diagnosis & Hypothesis Testing
|
||||
- Apply the scientific method: form hypotheses, test them with evidence
|
||||
- **The 3-Fix Rule**: If you cannot confidently identify the root cause after 3 hypotheses, STOP and reassess your assumptions — you may be looking at the wrong system or the wrong layer
|
||||
- Check the most common causes first (Occam's Razor): DNS, certificates, disk space, permissions, recent deployments
|
||||
- Differentiate between symptoms and causes — treat causes, not symptoms
|
||||
- Use binary search to narrow scope: which component, which layer, which change
|
||||
|
||||
### Phase 3: Root Cause Analysis with 5-Whys
|
||||
- Each "Why" must be backed by evidence, not speculation
|
||||
- If you cannot provide evidence for a "Why", state what investigation is needed to confirm
|
||||
- Look for systemic issues, not just proximate causes
|
||||
- The root cause should explain ALL observed symptoms, not just some
|
||||
- Common root cause categories: configuration drift, capacity exhaustion, dependency failure, race condition, human error in process
|
||||
|
||||
### Phase 4: Resolution & Prevention
|
||||
- **Immediate fix**: What stops the bleeding right now? (rollback, restart, failover)
|
||||
- **Permanent fix**: What prevents recurrence? (code fix, config change, automation)
|
||||
- **Runbook update**: Document the fix for future oncall engineers
|
||||
- Verify the fix resolves ALL symptoms, not just the primary one
|
||||
- Monitor for regression after applying the fix
|
||||
|
||||
### Phase 5: Post-Incident Review
|
||||
- Calculate incident metrics: MTTD (detect), MTTA (acknowledge), MTTR (resolve)
|
||||
- Conduct blameless post-mortem focused on systems and processes
|
||||
- Identify action items with owners and due dates
|
||||
- Categories: monitoring gaps, process improvements, technical debt, training needs
|
||||
- Ask: "What would have prevented this? What would have detected it faster? What would have resolved it faster?"
|
||||
|
||||
### Communication Practices
|
||||
- State your current phase explicitly (e.g., "We are in Phase 2: Diagnosis")
|
||||
- Summarize findings at each phase transition
|
||||
- Flag assumptions clearly: "ASSUMPTION: ..." vs "CONFIRMED: ..."
|
||||
- When advancing the Why level, explicitly state the evidence chain
|
||||
`;
|
||||
|
||||
export function getDomainPrompt(domainId: string): string {
|
||||
const domainSpecific = domainPrompts[domainId] ?? "";
|
||||
if (!domainSpecific) return "";
|
||||
return domainSpecific + INCIDENT_RESPONSE_FRAMEWORK;
|
||||
return domainPrompts[domainId] ?? "";
|
||||
}
|
||||
|
||||
@ -74,11 +74,9 @@ export interface FiveWhyEntry {
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
issue_id: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
@ -106,7 +104,6 @@ export interface IssueDetail {
|
||||
image_attachments: ImageAttachment[];
|
||||
resolution_steps: ResolutionStep[];
|
||||
conversations: AiConversation[];
|
||||
timeline_events: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface IssueSummary {
|
||||
@ -271,8 +268,8 @@ export interface TriageMessage {
|
||||
export const analyzeLogsCmd = (issueId: string, logFileIds: string[], providerConfig: ProviderConfig) =>
|
||||
invoke<AnalysisResult>("analyze_logs", { issueId, logFileIds, providerConfig });
|
||||
|
||||
export const chatMessageCmd = (issueId: string, message: string, providerConfig: ProviderConfig, systemPrompt?: string) =>
|
||||
invoke<ChatResponse>("chat_message", { issueId, message, providerConfig, systemPrompt: systemPrompt ?? null });
|
||||
export const chatMessageCmd = (issueId: string, message: string, providerConfig: ProviderConfig) =>
|
||||
invoke<ChatResponse>("chat_message", { issueId, message, providerConfig });
|
||||
|
||||
export const listProvidersCmd = () => invoke<ProviderInfo[]>("list_providers");
|
||||
|
||||
@ -364,11 +361,8 @@ export const addFiveWhyCmd = (
|
||||
export const updateFiveWhyCmd = (entryId: string, answer: string) =>
|
||||
invoke<void>("update_five_why", { entryId, answer });
|
||||
|
||||
export const addTimelineEventCmd = (issueId: string, eventType: string, description: string, metadata?: string) =>
|
||||
invoke<TimelineEvent>("add_timeline_event", { issueId, eventType, description, metadata: metadata ?? null });
|
||||
|
||||
export const getTimelineEventsCmd = (issueId: string) =>
|
||||
invoke<TimelineEvent[]>("get_timeline_events", { issueId });
|
||||
export const addTimelineEventCmd = (issueId: string, eventType: string, description: string) =>
|
||||
invoke<TimelineEvent>("add_timeline_event", { issueId, eventType, description });
|
||||
|
||||
// ─── Document commands ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { DocEditor } from "@/components/DocEditor";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
generatePostmortemCmd,
|
||||
addTimelineEventCmd,
|
||||
|
||||
updateDocumentCmd,
|
||||
exportDocumentCmd,
|
||||
type Document_,
|
||||
@ -28,7 +28,6 @@ export default function Postmortem() {
|
||||
const generated = await generatePostmortemCmd(id);
|
||||
setDoc(generated);
|
||||
setContent(generated.content_md);
|
||||
addTimelineEventCmd(id, "postmortem_generated", "Post-mortem document generated").catch(() => {});
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
@ -55,7 +54,6 @@ export default function Postmortem() {
|
||||
try {
|
||||
const path = await exportDocumentCmd(doc.id, doc.title, content, format, "");
|
||||
setError(`Document exported to: ${path}`);
|
||||
addTimelineEventCmd(id!, "document_exported", `Post-mortem exported as ${format}`).catch(() => {});
|
||||
setTimeout(() => setError(null), 5000);
|
||||
} catch (err) {
|
||||
setError(`Export failed: ${String(err)}`);
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
generateRcaCmd,
|
||||
updateDocumentCmd,
|
||||
exportDocumentCmd,
|
||||
addTimelineEventCmd,
|
||||
type Document_,
|
||||
} from "@/lib/tauriCommands";
|
||||
|
||||
@ -30,7 +29,6 @@ export default function RCA() {
|
||||
const generated = await generateRcaCmd(id);
|
||||
setDoc(generated);
|
||||
setContent(generated.content_md);
|
||||
addTimelineEventCmd(id, "rca_generated", "RCA document generated").catch(() => {});
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
@ -57,7 +55,6 @@ export default function RCA() {
|
||||
try {
|
||||
const path = await exportDocumentCmd(doc.id, doc.title, content, format, "");
|
||||
setError(`Document exported to: ${path}`);
|
||||
addTimelineEventCmd(id!, "document_exported", `RCA exported as ${format}`).catch(() => {});
|
||||
setTimeout(() => setError(null), 5000);
|
||||
} catch (err) {
|
||||
setError(`Export failed: ${String(err)}`);
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
updateIssueCmd,
|
||||
addFiveWhyCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
import { getDomainPrompt } from "@/lib/domainPrompts";
|
||||
import type { TriageMessage } from "@/lib/tauriCommands";
|
||||
|
||||
const CLOSE_PATTERNS = [
|
||||
@ -168,8 +167,7 @@ export default function Triage() {
|
||||
setPendingFiles([]);
|
||||
|
||||
try {
|
||||
const systemPrompt = currentIssue ? getDomainPrompt(currentIssue.category) : undefined;
|
||||
const response = await chatMessageCmd(id, aiMessage, provider, systemPrompt);
|
||||
const response = await chatMessageCmd(id, aiMessage, provider);
|
||||
const assistantMsg: TriageMessage = {
|
||||
id: `asst-${Date.now()}`,
|
||||
issue_id: id,
|
||||
|
||||
@ -42,8 +42,11 @@ describe("Audit Log", () => {
|
||||
it("displays audit entries", async () => {
|
||||
render(<Security />);
|
||||
|
||||
// Wait for table to appear after async audit data loads
|
||||
const table = await screen.findByRole("table");
|
||||
// Wait for audit log to load
|
||||
await screen.findByText("Audit Log");
|
||||
|
||||
// Check that the table has rows (header + data rows)
|
||||
const table = screen.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
const rows = screen.getAllByRole("row");
|
||||
@ -53,7 +56,9 @@ describe("Audit Log", () => {
|
||||
it("provides way to view transmitted data details", async () => {
|
||||
render(<Security />);
|
||||
|
||||
// Wait for async data to load and render the table
|
||||
await screen.findByText("Audit Log");
|
||||
|
||||
// Should have View/Hide buttons for expanding details
|
||||
const viewButtons = await screen.findAllByRole("button", { name: /View/i });
|
||||
expect(viewButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
@ -61,13 +66,14 @@ describe("Audit Log", () => {
|
||||
it("details column or button exists for viewing data", async () => {
|
||||
render(<Security />);
|
||||
|
||||
// Wait for async data to load and render the table
|
||||
await screen.findByRole("table");
|
||||
await screen.findByText("Audit Log");
|
||||
|
||||
// The audit log should have a Details column header
|
||||
const detailsHeader = screen.getByText("Details");
|
||||
expect(detailsHeader).toBeInTheDocument();
|
||||
|
||||
const viewButtons = screen.getAllByRole("button", { name: /View/i });
|
||||
// Should have view buttons
|
||||
const viewButtons = await screen.findAllByRole("button", { name: /View/i });
|
||||
expect(viewButtons.length).toBe(2); // One for each mock entry
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getDomainPrompt, DOMAINS, INCIDENT_RESPONSE_FRAMEWORK } from "@/lib/domainPrompts";
|
||||
|
||||
describe("Domain Prompts with Incident Response Framework", () => {
|
||||
it("exports INCIDENT_RESPONSE_FRAMEWORK constant", () => {
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toBeDefined();
|
||||
expect(typeof INCIDENT_RESPONSE_FRAMEWORK).toBe("string");
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("framework contains all 5 phases", () => {
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 1: Detection & Evidence Gathering");
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 2: Diagnosis & Hypothesis Testing");
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 3: Root Cause Analysis with 5-Whys");
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 4: Resolution & Prevention");
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 5: Post-Incident Review");
|
||||
});
|
||||
|
||||
it("framework contains the 3-Fix Rule", () => {
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("3-Fix Rule");
|
||||
});
|
||||
|
||||
it("framework contains communication practices", () => {
|
||||
expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Communication Practices");
|
||||
});
|
||||
|
||||
it("all defined domains include incident response methodology", () => {
|
||||
for (const domain of DOMAINS) {
|
||||
const prompt = getDomainPrompt(domain.id);
|
||||
if (prompt) {
|
||||
expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY");
|
||||
expect(prompt).toContain("Phase 1:");
|
||||
expect(prompt).toContain("Phase 5:");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty string for unknown domain", () => {
|
||||
expect(getDomainPrompt("nonexistent_domain")).toBe("");
|
||||
expect(getDomainPrompt("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves existing Linux domain content", () => {
|
||||
const prompt = getDomainPrompt("linux");
|
||||
expect(prompt).toContain("senior Linux systems engineer");
|
||||
expect(prompt).toContain("RHEL");
|
||||
expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY");
|
||||
});
|
||||
|
||||
it("preserves existing Kubernetes domain content", () => {
|
||||
const prompt = getDomainPrompt("kubernetes");
|
||||
expect(prompt).toContain("Kubernetes platform engineer");
|
||||
expect(prompt).toContain("k3s");
|
||||
expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY");
|
||||
});
|
||||
|
||||
it("preserves existing Network domain content", () => {
|
||||
const prompt = getDomainPrompt("network");
|
||||
expect(prompt).toContain("network engineer");
|
||||
expect(prompt).toContain("Fortigate");
|
||||
expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY");
|
||||
});
|
||||
});
|
||||
@ -35,7 +35,6 @@ const mockIssueDetail = {
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
timeline_events: [],
|
||||
};
|
||||
|
||||
describe("Resolution Page", () => {
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const mockInvoke = vi.mocked(invoke);
|
||||
|
||||
describe("Timeline Event Commands", () => {
|
||||
beforeEach(() => {
|
||||
mockInvoke.mockReset();
|
||||
});
|
||||
|
||||
it("addTimelineEventCmd calls invoke with correct params", async () => {
|
||||
const mockEvent = {
|
||||
id: "te-1",
|
||||
issue_id: "issue-1",
|
||||
event_type: "triage_started",
|
||||
description: "Started",
|
||||
metadata: "{}",
|
||||
created_at: "2025-01-15 10:00:00 UTC",
|
||||
};
|
||||
mockInvoke.mockResolvedValueOnce(mockEvent as never);
|
||||
|
||||
const { addTimelineEventCmd } = await import("@/lib/tauriCommands");
|
||||
const result = await addTimelineEventCmd("issue-1", "triage_started", "Started");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("add_timeline_event", {
|
||||
issueId: "issue-1",
|
||||
eventType: "triage_started",
|
||||
description: "Started",
|
||||
metadata: null,
|
||||
});
|
||||
expect(result).toEqual(mockEvent);
|
||||
});
|
||||
|
||||
it("addTimelineEventCmd passes metadata when provided", async () => {
|
||||
mockInvoke.mockResolvedValueOnce({} as never);
|
||||
|
||||
const { addTimelineEventCmd } = await import("@/lib/tauriCommands");
|
||||
await addTimelineEventCmd("issue-1", "log_uploaded", "File uploaded", '{"file":"app.log"}');
|
||||
expect(mockInvoke).toHaveBeenCalledWith("add_timeline_event", {
|
||||
issueId: "issue-1",
|
||||
eventType: "log_uploaded",
|
||||
description: "File uploaded",
|
||||
metadata: '{"file":"app.log"}',
|
||||
});
|
||||
});
|
||||
|
||||
it("getTimelineEventsCmd calls invoke with correct params", async () => {
|
||||
mockInvoke.mockResolvedValueOnce([] as never);
|
||||
|
||||
const { getTimelineEventsCmd } = await import("@/lib/tauriCommands");
|
||||
const result = await getTimelineEventsCmd("issue-1");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_timeline_events", { issueId: "issue-1" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user