diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..38957264 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +# Force use of system OpenSSL instead of vendored OpenSSL source builds. +OPENSSL_NO_VENDOR = "1" diff --git a/README.md b/README.md index 59f82c81..257832b1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TFTSR — IT Triage & RCA Desktop Application +# Troubleshooting and RCA Assistant A structured, AI-backed desktop tool for IT incident triage, 5-Whys root cause analysis, RCA document generation, and blameless post-mortems. Runs fully offline via Ollama local models, or connects to cloud AI providers. @@ -166,7 +166,7 @@ To use Claude via AWS Bedrock (ideal for enterprise environments with existing A nohup litellm --config ~/.litellm/config.yaml --port 8000 > ~/.litellm/litellm.log 2>&1 & ``` -4. **Configure in TFTSR:** +4. **Configure in Troubleshooting and RCA Assistant:** - Provider: **OpenAI** (OpenAI-compatible) - Base URL: `http://localhost:8000/v1` - API Key: `sk-your-secure-key` (from config) diff --git a/docs/wiki/AI-Providers.md b/docs/wiki/AI-Providers.md index 91880a0f..0630b1ad 100644 --- a/docs/wiki/AI-Providers.md +++ b/docs/wiki/AI-Providers.md @@ -113,7 +113,7 @@ The domain prompt is injected as the first `system` role message in every new co --- -## 6. Custom Provider (MSI GenAI & Others) +## 6. Custom Provider (Custom REST & Others) **Status:** ✅ **Implemented** (v0.2.6) @@ -137,25 +137,26 @@ Standard OpenAI `/chat/completions` endpoint with Bearer authentication. --- -### Format: MSI GenAI +### Format: Custom REST **Motorola Solutions Internal GenAI Service** — Enterprise AI platform with centralized cost tracking and model access. | Field | Value | |-------|-------| | `config.provider_type` | `"custom"` | -| `config.api_format` | `"msi_genai"` | +| `config.api_format` | `"custom_rest"` | | API URL | `https://genai-service.commandcentral.com/app-gateway` (prod)
`https://genai-service.stage.commandcentral.com/app-gateway` (stage) | | Auth Header | `x-msi-genai-api-key` | | Auth Prefix | `` (empty - no Bearer prefix) | | Endpoint Path | `` (empty - URL includes full path `/api/v2/chat`) | -**Available Models:** +**Available Models (dropdown in Settings):** - `VertexGemini` — Gemini 2.0 Flash (Private/GCP) - `Claude-Sonnet-4` — Claude Sonnet 4 (Public/Anthropic) - `ChatGPT4o` — GPT-4o (Public/OpenAI) - `ChatGPT-5_2-Chat` — GPT-4.5 (Public/OpenAI) -- See [GenAI API User Guide](../GenAI%20API%20User%20Guide.md) for full model list +- Full list is sourced from [GenAI API User Guide](../GenAI%20API%20User%20Guide.md) +- Includes a `Custom model...` option to manually enter any model ID **Request Format:** ```json @@ -187,9 +188,9 @@ Standard OpenAI `/chat/completions` endpoint with Bearer authentication. **Configuration (Settings → AI Providers → Add Provider):** ``` -Name: MSI GenAI +Name: Custom REST (MSI GenAI) Type: Custom -API Format: MSI GenAI +API Format: Custom REST API URL: https://genai-service.stage.commandcentral.com/app-gateway Model: VertexGemini API Key: (your MSI GenAI API key from portal) @@ -208,13 +209,13 @@ Auth Prefix: (leave empty) | Error | Cause | Solution | |-------|-------|----------| | 403 Forbidden | Invalid API key or insufficient permissions | Verify key in MSI GenAI portal, check model access | -| Missing `userId` field | Configuration not saved | Ensure UI shows User ID field when `api_format=msi_genai` | +| Missing `userId` field | Configuration not saved | Ensure UI shows User ID field when `api_format=custom_rest` | | No conversation history | `sessionId` not persisted | Session ID stored in `ProviderConfig.session_id` — currently per-provider, not per-conversation | **Implementation Details:** -- Backend: `src-tauri/src/ai/openai.rs::chat_msi_genai()` +- Backend: `src-tauri/src/ai/openai.rs::chat_custom_rest()` - Schema: `src-tauri/src/state.rs::ProviderConfig` (added `user_id`, `api_format`, custom auth fields) -- Frontend: `src/pages/Settings/AIProviders.tsx` (conditional UI for MSI GenAI) +- Frontend: `src/pages/Settings/AIProviders.tsx` (conditional UI for Custom REST + model dropdown) - CSP whitelist: `https://genai-service.stage.commandcentral.com` and production domain --- @@ -228,9 +229,9 @@ All providers support the following optional configuration fields (v0.2.6+): | `custom_endpoint_path` | `Option` | Override endpoint path | `/chat/completions` | | `custom_auth_header` | `Option` | Custom auth header name | `Authorization` | | `custom_auth_prefix` | `Option` | Prefix before API key | `Bearer ` | -| `api_format` | `Option` | API format (`openai` or `msi_genai`) | `openai` | +| `api_format` | `Option` | API format (`openai` or `custom_rest`) | `openai` | | `session_id` | `Option` | Session ID for stateful APIs | None | -| `user_id` | `Option` | User ID for cost tracking (MSI GenAI) | None | +| `user_id` | `Option` | User ID for cost tracking (Custom REST MSI contract) | None | **Backward Compatibility:** All fields are optional and default to OpenAI-compatible behavior. Existing provider configurations are unaffected. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index ebbe7feb..fe15cb0c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,6 +1,6 @@ -# TFTSR — IT Triage & RCA Desktop Application +# Troubleshooting and RCA Assistant -**TFTSR** is a secure desktop application for guided IT incident triage, root cause analysis (RCA), and post-mortem documentation. Built with Tauri 2.x (Rust + WebView) and React 18. +**Troubleshooting and RCA Assistant** is a secure desktop application for guided IT incident triage, root cause analysis (RCA), and post-mortem documentation. Built with Tauri 2.x (Rust + WebView) and React 18. **CI:** ![build](http://172.0.0.29:3000/sarman/tftsr-devops_investigation/actions/workflows/test.yml/badge.svg) — rustfmt · clippy · 64 Rust tests · tsc · vitest — all green @@ -25,7 +25,7 @@ - **5-Whys AI Triage** — Interactive guided root cause analysis via multi-turn AI chat - **PII Auto-Redaction** — Detects and redacts sensitive data before any AI send - **Multi-Provider AI** — OpenAI, Anthropic Claude, Google Gemini, Mistral, AWS Bedrock (via LiteLLM), MSI GenAI (Motorola internal), local Ollama (fully offline) -- **Custom Provider Support** — Flexible authentication (Bearer, custom headers) and API formats (OpenAI-compatible, MSI GenAI) +- **Custom Provider Support** — Flexible authentication (Bearer, custom headers) and API formats (OpenAI-compatible, Custom REST) - **External Integrations** — Confluence, ServiceNow, Azure DevOps with OAuth2 PKCE flows - **SQLCipher AES-256** — All issue history and credentials encrypted at rest - **RCA + Post-Mortem Generation** — Auto-populated Markdown templates, exportable as MD/PDF diff --git a/index.html b/index.html index a432c7b0..7edb9234 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - TFTSR — IT Triage & RCA + Troubleshooting and RCA Assistant
diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index ec6d3760..28287212 100644 --- a/src-tauri/.cargo/config.toml +++ b/src-tauri/.cargo/config.toml @@ -4,3 +4,8 @@ # error. The desktop binary links against rlib (static), so cdylib exports # are unused at runtime. rustflags = ["-C", "link-arg=-Wl,--exclude-all-symbols"] + +[env] +# Use system OpenSSL instead of vendoring from source (which requires Perl modules +# unavailable on some environments and breaks clippy/check). +OPENSSL_NO_VENDOR = "1" diff --git a/src-tauri/src/ai/mistral.rs b/src-tauri/src/ai/mistral.rs index 4f62c915..3fc2192d 100644 --- a/src-tauri/src/ai/mistral.rs +++ b/src-tauri/src/ai/mistral.rs @@ -47,7 +47,10 @@ impl Provider for MistralProvider { let resp = client .post(&url) - .header("Authorization", format!("Bearer {}", config.api_key)) + .header( + "Authorization", + format!("Bearer {api_key}", api_key = config.api_key), + ) .header("Content-Type", "application/json") .json(&body) .send() diff --git a/src-tauri/src/ai/openai.rs b/src-tauri/src/ai/openai.rs index cd2270d4..7972c2b9 100644 --- a/src-tauri/src/ai/openai.rs +++ b/src-tauri/src/ai/openai.rs @@ -6,6 +6,10 @@ use crate::state::ProviderConfig; pub struct OpenAiProvider; +fn is_custom_rest_format(api_format: Option<&str>) -> bool { + matches!(api_format, Some("custom_rest") | Some("msi_genai")) +} + #[async_trait] impl Provider for OpenAiProvider { fn name(&self) -> &str { @@ -29,17 +33,39 @@ impl Provider for OpenAiProvider { messages: Vec, config: &ProviderConfig, ) -> anyhow::Result { - // Check if using MSI GenAI format + // Check if using custom REST format let api_format = config.api_format.as_deref().unwrap_or("openai"); - if api_format == "msi_genai" { - self.chat_msi_genai(messages, config).await + // Backward compatibility: accept legacy msi_genai identifier + if is_custom_rest_format(Some(api_format)) { + self.chat_custom_rest(messages, config).await } else { self.chat_openai(messages, config).await } } } +#[cfg(test)] +mod tests { + use super::is_custom_rest_format; + + #[test] + fn custom_rest_format_is_recognized() { + assert!(is_custom_rest_format(Some("custom_rest"))); + } + + #[test] + fn legacy_msi_format_is_recognized_for_compatibility() { + assert!(is_custom_rest_format(Some("msi_genai"))); + } + + #[test] + fn openai_format_is_not_custom_rest() { + assert!(!is_custom_rest_format(Some("openai"))); + assert!(!is_custom_rest_format(None)); + } +} + impl OpenAiProvider { /// OpenAI-compatible API format (default) async fn chat_openai( @@ -54,7 +80,8 @@ impl OpenAiProvider { .custom_endpoint_path .as_deref() .unwrap_or("/chat/completions"); - let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path); + let api_url = config.api_url.trim_end_matches('/'); + let url = format!("{api_url}{endpoint_path}"); let mut body = serde_json::json!({ "model": config.model, @@ -75,7 +102,7 @@ impl OpenAiProvider { .as_deref() .unwrap_or("Authorization"); let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or("Bearer "); - let auth_value = format!("{}{}", auth_prefix, config.api_key); + let auth_value = format!("{auth_prefix}{api_key}", api_key = config.api_key); let resp = client .post(&url) @@ -112,8 +139,8 @@ impl OpenAiProvider { }) } - /// MSI GenAI custom format - async fn chat_msi_genai( + /// Custom REST format (MSI GenAI payload contract) + async fn chat_custom_rest( &self, messages: Vec, config: &ProviderConfig, @@ -122,7 +149,8 @@ impl OpenAiProvider { // Use custom endpoint path, default to empty (API URL already includes /api/v2/chat) let endpoint_path = config.custom_endpoint_path.as_deref().unwrap_or(""); - let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path); + let api_url = config.api_url.trim_end_matches('/'); + let url = format!("{api_url}{endpoint_path}"); // Extract system message if present let system_message = messages @@ -171,19 +199,19 @@ impl OpenAiProvider { body["modelConfig"] = model_config; } - // Use custom auth header and prefix (no prefix for MSI GenAI) + // Use custom auth header and prefix (no prefix for this custom REST contract) let auth_header = config .custom_auth_header .as_deref() .unwrap_or("x-msi-genai-api-key"); let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or(""); - let auth_value = format!("{}{}", auth_prefix, config.api_key); + let auth_value = format!("{auth_prefix}{api_key}", api_key = config.api_key); let resp = client .post(&url) .header(auth_header, auth_value) .header("Content-Type", "application/json") - .header("X-msi-genai-client", "tftsr-devops-investigation") + .header("X-msi-genai-client", "troubleshooting-rca-assistant") .json(&body) .send() .await?; @@ -191,7 +219,7 @@ impl OpenAiProvider { if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await?; - anyhow::bail!("MSI GenAI API error {status}: {text}"); + anyhow::bail!("Custom REST API error {status}: {text}"); } let json: serde_json::Value = resp.json().await?; @@ -210,7 +238,7 @@ impl OpenAiProvider { Ok(ChatResponse { content, model: config.model.clone(), - usage: None, // MSI GenAI doesn't provide token usage in response + usage: None, // This custom REST contract doesn't provide token usage in response }) } } diff --git a/src-tauri/src/audit/log.rs b/src-tauri/src/audit/log.rs index 7db8d241..45fb516c 100644 --- a/src-tauri/src/audit/log.rs +++ b/src-tauri/src/audit/log.rs @@ -97,9 +97,9 @@ mod tests { for i in 0..5 { write_audit_event( &conn, - &format!("action_{}", i), + &format!("action_{i}"), "test", - &format!("id_{}", i), + &format!("id_{i}"), "{}", ) .unwrap(); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index d9812462..216d8f2d 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -246,7 +246,7 @@ pub async fn chat_message( "api_url": provider_config.api_url, "user_message": user_msg.content, "response_preview": if response.content.len() > 200 { - format!("{}...", &response.content[..200]) + format!("{preview}...", preview = &response.content[..200]) } else { response.content.clone() }, @@ -278,7 +278,9 @@ pub async fn test_provider_connection( let provider = create_provider(&provider_config); let messages = vec![Message { role: "user".into(), - content: "Reply with exactly: TFTSR connection test successful.".into(), + content: + "Reply with exactly: Troubleshooting and RCA Assistant connection test successful." + .into(), }]; provider .chat(messages, &provider_config) diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index fbe5a77a..c76e96ac 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -295,19 +295,31 @@ pub async fn list_issues( let mut params: Vec> = vec![]; if let Some(ref status) = filter.status { - sql.push_str(&format!(" AND i.status = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND i.status = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(status.clone())); } if let Some(ref severity) = filter.severity { - sql.push_str(&format!(" AND i.severity = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND i.severity = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(severity.clone())); } if let Some(ref category) = filter.category { - sql.push_str(&format!(" AND i.category = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND i.category = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(category.clone())); } if let Some(ref domain) = filter.domain { - sql.push_str(&format!(" AND i.category = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND i.category = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(domain.clone())); } if let Some(ref search) = filter.search { @@ -321,9 +333,9 @@ pub async fn list_issues( sql.push_str(" ORDER BY i.updated_at DESC"); sql.push_str(&format!( - " LIMIT ?{} OFFSET ?{}", - params.len() + 1, - params.len() + 2 + " LIMIT ?{limit_index} OFFSET ?{offset_index}", + limit_index = params.len() + 1, + offset_index = params.len() + 2 )); params.push(Box::new(limit)); params.push(Box::new(offset)); diff --git a/src-tauri/src/commands/docs.rs b/src-tauri/src/commands/docs.rs index 11e6dc18..25b51d7a 100644 --- a/src-tauri/src/commands/docs.rs +++ b/src-tauri/src/commands/docs.rs @@ -34,7 +34,7 @@ pub async fn generate_rca( id: doc_id.clone(), issue_id: issue_id.clone(), doc_type: "rca".to_string(), - title: format!("RCA: {}", issue_detail.issue.title), + title: format!("RCA: {title}", title = issue_detail.issue.title), content_md: content_md.clone(), created_at: now.clone(), updated_at: now, @@ -49,7 +49,7 @@ pub async fn generate_rca( "doc_title": document.title, "content_length": content_md.len(), "content_preview": if content_md.len() > 300 { - format!("{}...", &content_md[..300]) + format!("{preview}...", preview = &content_md[..300]) } else { content_md.clone() }, @@ -93,7 +93,7 @@ pub async fn generate_postmortem( id: doc_id.clone(), issue_id: issue_id.clone(), doc_type: "postmortem".to_string(), - title: format!("Post-Mortem: {}", issue_detail.issue.title), + title: format!("Post-Mortem: {title}", title = issue_detail.issue.title), content_md: content_md.clone(), created_at: now.clone(), updated_at: now, @@ -108,7 +108,7 @@ pub async fn generate_postmortem( "doc_title": document.title, "content_length": content_md.len(), "content_preview": if content_md.len() > 300 { - format!("{}...", &content_md[..300]) + format!("{preview}...", preview = &content_md[..300]) } else { content_md.clone() }, diff --git a/src-tauri/src/commands/integrations.rs b/src-tauri/src/commands/integrations.rs index 1240c7d7..a0eb8496 100644 --- a/src-tauri/src/commands/integrations.rs +++ b/src-tauri/src/commands/integrations.rs @@ -94,7 +94,7 @@ pub async fn initiate_oauth( let (mut callback_rx, shutdown_tx) = crate::integrations::callback_server::start_callback_server(8765) .await - .map_err(|e| format!("Failed to start callback server: {}", e))?; + .map_err(|e| format!("Failed to start callback server: {e}"))?; // Store shutdown channel { @@ -123,7 +123,7 @@ pub async fn initiate_oauth( let mut oauth_state = match OAUTH_STATE.lock() { Ok(state) => state, Err(e) => { - tracing::error!("Failed to lock OAuth state: {}", e); + tracing::error!("Failed to lock OAuth state: {e}"); continue; } }; @@ -148,7 +148,7 @@ pub async fn initiate_oauth( match result { Ok(_) => tracing::info!("OAuth callback handled successfully"), - Err(e) => tracing::error!("OAuth callback failed: {}", e), + Err(e) => tracing::error!("OAuth callback failed: {e}"), } } @@ -166,7 +166,7 @@ pub async fn initiate_oauth( { let mut oauth_state = OAUTH_STATE .lock() - .map_err(|e| format!("Failed to lock OAuth state: {}", e))?; + .map_err(|e| format!("Failed to lock OAuth state: {e}"))?; oauth_state.insert( state_key.clone(), (service.clone(), pkce.code_verifier.clone()), @@ -193,7 +193,7 @@ pub async fn initiate_oauth( // ServiceNow uses basic auth, not OAuth2 return Err("ServiceNow uses basic authentication, not OAuth2".to_string()); } - _ => return Err(format!("Unknown service: {}", service)), + _ => return Err(format!("Unknown service: {service}")), }; let auth_url = crate::integrations::auth::build_auth_url( @@ -231,7 +231,7 @@ async fn handle_oauth_callback_internal( .unwrap_or_else(|_| "ado-client-id-placeholder".to_string()), "http://localhost:8765/callback", ), - _ => return Err(format!("Unknown service: {}", service)), + _ => return Err(format!("Unknown service: {service}")), }; // Exchange authorization code for access token @@ -265,7 +265,7 @@ async fn handle_oauth_callback_internal( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; db.execute( "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at) @@ -279,7 +279,7 @@ async fn handle_oauth_callback_internal( expires_at, ], ) - .map_err(|e| format!("Failed to store credentials: {}", e))?; + .map_err(|e| format!("Failed to store credentials: {e}"))?; // Log audit event let audit_details = serde_json::json!({ @@ -301,7 +301,7 @@ async fn handle_oauth_callback_internal( audit_details.to_string(), ], ) - .map_err(|e| format!("Failed to log audit event: {}", e))?; + .map_err(|e| format!("Failed to log audit event: {e}"))?; Ok(()) } @@ -319,7 +319,7 @@ pub async fn handle_oauth_callback( let verifier = { let mut oauth_state = OAUTH_STATE .lock() - .map_err(|e| format!("Failed to lock OAuth state: {}", e))?; + .map_err(|e| format!("Failed to lock OAuth state: {e}"))?; oauth_state .remove(&state_key) .map(|(_svc, ver)| ver) @@ -514,21 +514,20 @@ pub async fn authenticate_with_webview( app_handle: tauri::AppHandle, app_state: State<'_, AppState>, ) -> Result { - let webview_id = format!("{}-auth", service); + let webview_id = format!("{service}-auth"); // Check if window already exists if let Some(existing_label) = app_state .integration_webviews .lock() - .map_err(|e| format!("Failed to lock webviews: {}", e))? + .map_err(|e| format!("Failed to lock webviews: {e}"))? .get(&service) { if app_handle.get_webview_window(existing_label).is_some() { return Ok(WebviewAuthResponse { success: true, message: format!( - "{} browser window is already open. Switch to it to log in.", - service + "{service} browser window is already open. Switch to it to log in." ), webview_id: existing_label.clone(), }); @@ -545,14 +544,13 @@ pub async fn authenticate_with_webview( app_state .integration_webviews .lock() - .map_err(|e| format!("Failed to lock webviews: {}", e))? + .map_err(|e| format!("Failed to lock webviews: {e}"))? .insert(service.clone(), webview_id.clone()); Ok(WebviewAuthResponse { success: true, message: format!( - "{} browser window opened. This window will stay open - use it to browse and authenticate. Cookies will be extracted automatically for API calls.", - service + "{service} browser window opened. This window will stay open - use it to browse and authenticate. Cookies will be extracted automatically for API calls." ), webview_id, }) @@ -582,8 +580,8 @@ pub async fn extract_cookies_from_webview( } // Encrypt and store cookies in database - let cookies_json = serde_json::to_string(&cookies) - .map_err(|e| format!("Failed to serialize cookies: {}", e))?; + let cookies_json = + serde_json::to_string(&cookies).map_err(|e| format!("Failed to serialize cookies: {e}"))?; let encrypted_cookies = crate::integrations::auth::encrypt_token(&cookies_json)?; let token_hash = { @@ -597,7 +595,7 @@ pub async fn extract_cookies_from_webview( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; db.execute( "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at) @@ -611,18 +609,18 @@ pub async fn extract_cookies_from_webview( None::, // Cookies don't have explicit expiry ], ) - .map_err(|e| format!("Failed to store cookies: {}", e))?; + .map_err(|e| format!("Failed to store cookies: {e}"))?; // Close the webview window if let Some(webview) = app_handle.get_webview_window(&webview_id) { webview .close() - .map_err(|e| format!("Failed to close webview: {}", e))?; + .map_err(|e| format!("Failed to close webview: {e}"))?; } Ok(ConnectionResult { success: true, - message: format!("{} authentication saved successfully", service), + message: format!("{service} authentication saved successfully"), }) } @@ -669,7 +667,12 @@ pub async fn save_manual_token( }; crate::integrations::servicenow::test_connection(&config).await } - _ => return Err(format!("Unknown service: {}", request.service)), + _ => { + return Err(format!( + "Unknown service: {service}", + service = request.service + )) + } }; // If test fails, don't save the token @@ -698,7 +701,7 @@ pub async fn save_manual_token( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; db.execute( "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at) @@ -712,7 +715,7 @@ pub async fn save_manual_token( None::, ], ) - .map_err(|e| format!("Failed to store token: {}", e))?; + .map_err(|e| format!("Failed to store token: {e}"))?; // Log audit event db.execute( @@ -732,11 +735,14 @@ pub async fn save_manual_token( .to_string(), ], ) - .map_err(|e| format!("Failed to log audit event: {}", e))?; + .map_err(|e| format!("Failed to log audit event: {e}"))?; Ok(ConnectionResult { success: true, - message: format!("{} token saved and validated successfully", request.service), + message: format!( + "{service} token saved and validated successfully", + service = request.service + ), }) } @@ -757,7 +763,7 @@ pub async fn get_fresh_cookies_from_webview( let webviews = app_state .integration_webviews .lock() - .map_err(|e| format!("Failed to lock webviews: {}", e))?; + .map_err(|e| format!("Failed to lock webviews: {e}"))?; match webviews.get(service) { Some(label) => label.clone(), @@ -773,7 +779,7 @@ pub async fn get_fresh_cookies_from_webview( app_state .integration_webviews .lock() - .map_err(|e| format!("Failed to lock webviews: {}", e))? + .map_err(|e| format!("Failed to lock webviews: {e}"))? .remove(service); return Ok(None); } @@ -814,7 +820,7 @@ pub async fn save_integration_config( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; db.execute( "INSERT OR REPLACE INTO integration_config @@ -829,7 +835,7 @@ pub async fn save_integration_config( config.space_key, ], ) - .map_err(|e| format!("Failed to save integration config: {}", e))?; + .map_err(|e| format!("Failed to save integration config: {e}"))?; Ok(()) } @@ -843,11 +849,11 @@ pub async fn get_integration_config( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; let mut stmt = db .prepare("SELECT service, base_url, username, project_name, space_key FROM integration_config WHERE service = ?1") - .map_err(|e| format!("Failed to prepare query: {}", e))?; + .map_err(|e| format!("Failed to prepare query: {e}"))?; let config = stmt .query_row([&service], |row| { @@ -860,7 +866,7 @@ pub async fn get_integration_config( }) }) .optional() - .map_err(|e| format!("Failed to query integration config: {}", e))?; + .map_err(|e| format!("Failed to query integration config: {e}"))?; Ok(config) } @@ -873,13 +879,13 @@ pub async fn get_all_integration_configs( let db = app_state .db .lock() - .map_err(|e| format!("Failed to lock database: {}", e))?; + .map_err(|e| format!("Failed to lock database: {e}"))?; let mut stmt = db .prepare( "SELECT service, base_url, username, project_name, space_key FROM integration_config", ) - .map_err(|e| format!("Failed to prepare query: {}", e))?; + .map_err(|e| format!("Failed to prepare query: {e}"))?; let configs = stmt .query_map([], |row| { @@ -891,9 +897,9 @@ pub async fn get_all_integration_configs( space_key: row.get(4)?, }) }) - .map_err(|e| format!("Failed to query integration configs: {}", e))? + .map_err(|e| format!("Failed to query integration configs: {e}"))? .collect::, _>>() - .map_err(|e| format!("Failed to collect integration configs: {}", e))?; + .map_err(|e| format!("Failed to collect integration configs: {e}"))?; Ok(configs) } diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 7baf15a2..a74846df 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -98,20 +98,26 @@ pub async fn get_audit_log( let mut params: Vec> = vec![]; if let Some(ref action) = filter.action { - sql.push_str(&format!(" AND action = ?{}", params.len() + 1)); + sql.push_str(&format!(" AND action = ?{index}", index = params.len() + 1)); params.push(Box::new(action.clone())); } if let Some(ref entity_type) = filter.entity_type { - sql.push_str(&format!(" AND entity_type = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND entity_type = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(entity_type.clone())); } if let Some(ref entity_id) = filter.entity_id { - sql.push_str(&format!(" AND entity_id = ?{}", params.len() + 1)); + sql.push_str(&format!( + " AND entity_id = ?{index}", + index = params.len() + 1 + )); params.push(Box::new(entity_id.clone())); } sql.push_str(" ORDER BY timestamp DESC"); - sql.push_str(&format!(" LIMIT ?{}", params.len() + 1)); + sql.push_str(&format!(" LIMIT ?{index}", index = params.len() + 1)); params.push(Box::new(limit)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index e860aaef..865aebae 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -162,13 +162,13 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { // FTS5 virtual table creation can be skipped if FTS5 is not compiled in if let Err(e) = conn.execute_batch(sql) { if name.contains("fts") { - tracing::warn!("FTS5 not available, skipping: {}", e); + tracing::warn!("FTS5 not available, skipping: {e}"); } else { return Err(e.into()); } } conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; - tracing::info!("Applied migration: {}", name); + tracing::info!("Applied migration: {name}"); } } diff --git a/src-tauri/src/docs/postmortem.rs b/src-tauri/src/docs/postmortem.rs index 1a5631ff..8a8df607 100644 --- a/src-tauri/src/docs/postmortem.rs +++ b/src-tauri/src/docs/postmortem.rs @@ -5,15 +5,30 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { let mut md = String::new(); - md.push_str(&format!("# Blameless Post-Mortem: {}\n\n", issue.title)); + 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:** {}\n", issue.created_at)); - md.push_str(&format!("- **Severity:** {}\n", issue.severity)); - md.push_str(&format!("- **Category:** {}\n", issue.category)); - md.push_str(&format!("- **Status:** {}\n", issue.status)); - md.push_str(&format!("- **Last Updated:** {}\n", issue.updated_at)); + 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() { @@ -45,7 +60,10 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { md.push_str("## Timeline\n\n"); md.push_str("| Time (UTC) | Event |\n"); md.push_str("|------------|-------|\n"); - md.push_str(&format!("| {} | Issue created |\n", issue.created_at)); + 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")); } @@ -77,7 +95,10 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { if let Some(last) = detail.resolution_steps.last() { if !last.answer.is_empty() { - md.push_str(&format!("**Root Cause:** {}\n\n", last.answer)); + md.push_str(&format!( + "**Root Cause:** {answer}\n\n", + answer = last.answer + )); } } } @@ -127,7 +148,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { md.push_str("---\n\n"); md.push_str(&format!( - "_Generated by TFTSR IT Triage on {}_\n", + "_Generated by Troubleshooting and RCA Assistant on {}_\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") )); diff --git a/src-tauri/src/docs/rca.rs b/src-tauri/src/docs/rca.rs index cff36be7..66836996 100644 --- a/src-tauri/src/docs/rca.rs +++ b/src-tauri/src/docs/rca.rs @@ -5,16 +5,31 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { let mut md = String::new(); - md.push_str(&format!("# Root Cause Analysis: {}\n\n", issue.title)); + md.push_str(&format!( + "# Root Cause Analysis: {title}\n\n", + title = issue.title + )); md.push_str("## Issue Summary\n\n"); md.push_str("| Field | Value |\n"); md.push_str("|-------|-------|\n"); - md.push_str(&format!("| **Issue ID** | {} |\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)); + md.push_str(&format!("| **Issue ID** | {id} |\n", id = issue.id)); + md.push_str(&format!( + "| **Category** | {category} |\n", + category = issue.category + )); + md.push_str(&format!( + "| **Status** | {status} |\n", + status = issue.status + )); + md.push_str(&format!( + "| **Severity** | {severity} |\n", + severity = issue.severity + )); + md.push_str(&format!( + "| **Source** | {source} |\n", + source = issue.source + )); md.push_str(&format!( "| **Assigned To** | {} |\n", if issue.assigned_to.is_empty() { @@ -23,8 +38,14 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { &issue.assigned_to } )); - md.push_str(&format!("| **Created** | {} |\n", issue.created_at)); - md.push_str(&format!("| **Last Updated** | {} |\n", issue.updated_at)); + md.push_str(&format!( + "| **Created** | {created_at} |\n", + created_at = issue.created_at + )); + md.push_str(&format!( + "| **Last Updated** | {updated_at} |\n", + updated_at = issue.updated_at + )); if let Some(ref resolved) = issue.resolved_at { md.push_str(&format!("| **Resolved** | {resolved} |\n")); } @@ -47,12 +68,15 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { step.step_order, step.why_question )); if !step.answer.is_empty() { - md.push_str(&format!("**Answer:** {}\n\n", step.answer)); + md.push_str(&format!("**Answer:** {answer}\n\n", answer = step.answer)); } else { md.push_str("_Awaiting answer._\n\n"); } if !step.evidence.is_empty() { - md.push_str(&format!("**Evidence:** {}\n\n", step.evidence)); + md.push_str(&format!( + "**Evidence:** {evidence}\n\n", + evidence = step.evidence + )); } } } @@ -109,7 +133,7 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { md.push_str("---\n\n"); md.push_str(&format!( - "_Generated by TFTSR IT Triage on {}_\n", + "_Generated by Troubleshooting and RCA Assistant on {}_\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") )); diff --git a/src-tauri/src/integrations/auth.rs b/src-tauri/src/integrations/auth.rs index fc9d1dba..ed6ad7a1 100644 --- a/src-tauri/src/integrations/auth.rs +++ b/src-tauri/src/integrations/auth.rs @@ -88,7 +88,7 @@ pub async fn exchange_code( .form(¶ms) .send() .await - .map_err(|e| format!("Failed to send token exchange request: {}", e))?; + .map_err(|e| format!("Failed to send token exchange request: {e}"))?; if !resp.status().is_success() { return Err(format!( @@ -101,7 +101,7 @@ pub async fn exchange_code( let body: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse token response: {}", e))?; + .map_err(|e| format!("Failed to parse token response: {e}"))?; let access_token = body["access_token"] .as_str() @@ -208,7 +208,7 @@ pub fn encrypt_token(token: &str) -> Result { // Encrypt let ciphertext = cipher .encrypt(nonce, token.as_bytes()) - .map_err(|e| format!("Encryption failed: {}", e))?; + .map_err(|e| format!("Encryption failed: {e}"))?; // Prepend nonce to ciphertext let mut result = nonce_bytes.to_vec(); @@ -232,7 +232,7 @@ pub fn decrypt_token(encrypted: &str) -> Result { use base64::Engine; let data = STANDARD .decode(encrypted) - .map_err(|e| format!("Base64 decode failed: {}", e))?; + .map_err(|e| format!("Base64 decode failed: {e}"))?; if data.len() < 12 { return Err("Invalid encrypted data: too short".to_string()); @@ -256,9 +256,9 @@ pub fn decrypt_token(encrypted: &str) -> Result { // Decrypt let plaintext = cipher .decrypt(nonce, ciphertext) - .map_err(|e| format!("Decryption failed: {}", e))?; + .map_err(|e| format!("Decryption failed: {e}"))?; - String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8: {}", e)) + String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8: {e}")) } #[cfg(test)] @@ -365,7 +365,7 @@ mod tests { .create_async() .await; - let token_endpoint = format!("{}/oauth/token", server.url()); + let token_endpoint = format!("{server_url}/oauth/token", server_url = server.url()); let result = exchange_code( &token_endpoint, "test-client-id", @@ -397,7 +397,7 @@ mod tests { .create_async() .await; - let token_endpoint = format!("{}/oauth/token", server.url()); + let token_endpoint = format!("{server_url}/oauth/token", server_url = server.url()); let result = exchange_code( &token_endpoint, "test-client-id", @@ -421,7 +421,7 @@ mod tests { .create_async() .await; - let token_endpoint = format!("{}/oauth/token", server.url()); + let token_endpoint = format!("{server_url}/oauth/token", server_url = server.url()); let result = exchange_code( &token_endpoint, "test-client-id", diff --git a/src-tauri/src/integrations/azuredevops.rs b/src-tauri/src/integrations/azuredevops.rs index a8651516..76f04589 100644 --- a/src-tauri/src/integrations/azuredevops.rs +++ b/src-tauri/src/integrations/azuredevops.rs @@ -32,7 +32,7 @@ pub async fn test_connection(config: &AzureDevOpsConfig) -> Result Result Result Result Result Result, String> { let client = reqwest::Client::new(); - let url = format!("{}/rest/api/space", config.base_url.trim_end_matches('/')); + let base_url = config.base_url.trim_end_matches('/'); + let url = format!("{base_url}/rest/api/space"); let resp = client .get(&url) @@ -61,7 +63,7 @@ pub async fn list_spaces(config: &ConfluenceConfig) -> Result, String .query(&[("limit", "100")]) .send() .await - .map_err(|e| format!("Failed to list spaces: {}", e))?; + .map_err(|e| format!("Failed to list spaces: {e}"))?; if !resp.status().is_success() { return Err(format!( @@ -74,7 +76,7 @@ pub async fn list_spaces(config: &ConfluenceConfig) -> Result, String let body: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| format!("Failed to parse response: {e}"))?; let spaces = body["results"] .as_array() @@ -103,9 +105,9 @@ pub async fn search_pages( config.base_url.trim_end_matches('/') ); - let mut cql = format!("text ~ \"{}\"", query); + let mut cql = format!("text ~ \"{query}\""); if let Some(space) = space_key { - cql = format!("{} AND space = {}", cql, space); + cql = format!("{cql} AND space = {space}"); } let resp = client @@ -114,7 +116,7 @@ pub async fn search_pages( .query(&[("cql", &cql), ("limit", &"50".to_string())]) .send() .await - .map_err(|e| format!("Search failed: {}", e))?; + .map_err(|e| format!("Search failed: {e}"))?; if !resp.status().is_success() { return Err(format!( @@ -127,7 +129,7 @@ pub async fn search_pages( let body: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| format!("Failed to parse response: {e}"))?; let pages = body["results"] .as_array() @@ -140,7 +142,7 @@ pub async fn search_pages( id: page_id.to_string(), title: p["title"].as_str()?.to_string(), space_key: p["space"]["key"].as_str()?.to_string(), - url: format!("{}/pages/viewpage.action?pageId={}", base_url, page_id), + url: format!("{base_url}/pages/viewpage.action?pageId={page_id}"), }) }) .collect(); @@ -157,7 +159,8 @@ pub async fn publish_page( parent_page_id: Option<&str>, ) -> Result { let client = reqwest::Client::new(); - let url = format!("{}/rest/api/content", config.base_url.trim_end_matches('/')); + let base_url = config.base_url.trim_end_matches('/'); + let url = format!("{base_url}/rest/api/content"); let mut body = serde_json::json!({ "type": "page", @@ -182,7 +185,7 @@ pub async fn publish_page( .json(&body) .send() .await - .map_err(|e| format!("Failed to publish page: {}", e))?; + .map_err(|e| format!("Failed to publish page: {e}"))?; if !resp.status().is_success() { return Err(format!( @@ -195,7 +198,7 @@ pub async fn publish_page( let result: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| format!("Failed to parse response: {e}"))?; let page_id = result["id"].as_str().unwrap_or(""); let page_url = format!( @@ -245,7 +248,7 @@ pub async fn update_page( .json(&body) .send() .await - .map_err(|e| format!("Failed to update page: {}", e))?; + .map_err(|e| format!("Failed to update page: {e}"))?; if !resp.status().is_success() { return Err(format!( @@ -258,7 +261,7 @@ pub async fn update_page( let result: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| format!("Failed to parse response: {e}"))?; let updated_page_id = result["id"].as_str().unwrap_or(page_id); let page_url = format!( diff --git a/src-tauri/src/integrations/servicenow.rs b/src-tauri/src/integrations/servicenow.rs index b54d8254..a67f5c80 100644 --- a/src-tauri/src/integrations/servicenow.rs +++ b/src-tauri/src/integrations/servicenow.rs @@ -34,7 +34,7 @@ pub async fn test_connection(config: &ServiceNowConfig) -> Result Result Result { + let trimmed_base_url = base_url.trim_end_matches('/'); let login_url = match service { - "confluence" => format!("{}/login.action", base_url.trim_end_matches('/')), + "confluence" => format!("{trimmed_base_url}/login.action"), "azuredevops" => { // Azure DevOps login - user will be redirected through Microsoft SSO - format!("{}/_signin", base_url.trim_end_matches('/')) + format!("{trimmed_base_url}/_signin") } - "servicenow" => format!("{}/login.do", base_url.trim_end_matches('/')), - _ => return Err(format!("Unknown service: {}", service)), + "servicenow" => format!("{trimmed_base_url}/login.do"), + _ => return Err(format!("Unknown service: {service}")), }; tracing::info!( @@ -42,17 +43,15 @@ pub async fn authenticate_with_webview( ); // Create persistent browser window (stays open for browsing and fresh cookie extraction) - let webview_label = format!("{}-auth", service); + let webview_label = format!("{service}-auth"); let webview = WebviewWindowBuilder::new( &app_handle, &webview_label, - WebviewUrl::External( - login_url - .parse() - .map_err(|e| format!("Invalid URL: {}", e))?, - ), + WebviewUrl::External(login_url.parse().map_err(|e| format!("Invalid URL: {e}"))?), ) - .title(format!("{} Browser (TFTSR)", service)) + .title(format!( + "{service} Browser (Troubleshooting and RCA Assistant)" + )) .inner_size(1000.0, 800.0) .min_inner_size(800.0, 600.0) .resizable(true) @@ -60,12 +59,12 @@ pub async fn authenticate_with_webview( .focused(true) .visible(true) .build() - .map_err(|e| format!("Failed to create webview: {}", e))?; + .map_err(|e| format!("Failed to create webview: {e}"))?; // Focus the window webview .set_focus() - .map_err(|e| tracing::warn!("Failed to focus webview: {}", e)) + .map_err(|e| tracing::warn!("Failed to focus webview: {e}")) .ok(); // Wait for user to complete login @@ -147,7 +146,7 @@ pub async fn extract_cookies_via_ipc( match serde_json::from_str::(payload_str) { Ok(payload) => { if let Some(error_msg) = payload.get("error").and_then(|e| e.as_str()) { - let _ = tx.try_send(Err(format!("JavaScript error: {}", error_msg))); + let _ = tx.try_send(Err(format!("JavaScript error: {error_msg}"))); return; } @@ -158,8 +157,8 @@ pub async fn extract_cookies_via_ipc( let _ = tx.try_send(Ok(cookies)); } Err(e) => { - tracing::error!("Failed to parse cookies: {}", e); - let _ = tx.try_send(Err(format!("Failed to parse cookies: {}", e))); + tracing::error!("Failed to parse cookies: {e}"); + let _ = tx.try_send(Err(format!("Failed to parse cookies: {e}"))); } } } else { @@ -167,8 +166,8 @@ pub async fn extract_cookies_via_ipc( } } Err(e) => { - tracing::error!("Failed to parse event payload: {}", e); - let _ = tx.try_send(Err(format!("Failed to parse event payload: {}", e))); + tracing::error!("Failed to parse event payload: {e}"); + let _ = tx.try_send(Err(format!("Failed to parse event payload: {e}"))); } } }); @@ -176,7 +175,7 @@ pub async fn extract_cookies_via_ipc( // Inject the script into the webview webview_window .eval(cookie_extraction_script) - .map_err(|e| format!("Failed to inject cookie extraction script: {}", e))?; + .map_err(|e| format!("Failed to inject cookie extraction script: {e}"))?; tracing::info!("Cookie extraction script injected, waiting for response..."); @@ -199,7 +198,13 @@ pub async fn extract_cookies_via_ipc( pub fn cookies_to_header(cookies: &[Cookie]) -> String { cookies .iter() - .map(|c| format!("{}={}", c.name, c.value)) + .map(|c| { + format!( + "{name}={value}", + name = c.name.as_str(), + value = c.value.as_str() + ) + }) .collect::>() .join("; ") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9317957f..c83c0b2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,7 +21,7 @@ pub fn run() { ) .init(); - tracing::info!("Starting TFTSR application"); + tracing::info!("Starting Troubleshooting and RCA Assistant application"); // Determine data directory let data_dir = dirs_data_dir(); @@ -107,7 +107,7 @@ pub fn run() { commands::system::get_audit_log, ]) .run(tauri::generate_context!()) - .expect("Error running TFTSR application"); + .expect("Error running Troubleshooting and RCA Assistant application"); } /// Determine the application data directory. diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 36cce24d..97b51736 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -29,14 +29,14 @@ pub struct ProviderConfig { /// If None, defaults to "Bearer " #[serde(skip_serializing_if = "Option::is_none")] pub custom_auth_prefix: Option, - /// Optional: API format ("openai" or "msi_genai") + /// Optional: API format ("openai" or "custom_rest") /// If None, defaults to "openai" #[serde(skip_serializing_if = "Option::is_none")] pub api_format: Option, - /// Optional: Session ID for stateful APIs like MSI GenAI + /// Optional: Session ID for stateful custom REST APIs #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, - /// Optional: User ID for MSI GenAI (CORE ID email) + /// Optional: User ID for custom REST API cost tracking (CORE ID email) #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b3ebd7d0..5b7449b1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "productName": "TFTSR", + "productName": "Troubleshooting and RCA Assistant", "version": "0.2.10", "identifier": "com.tftsr.devops", "build": { @@ -14,7 +14,7 @@ }, "windows": [ { - "title": "TFTSR \u2014 IT Triage & RCA", + "title": "Troubleshooting and RCA Assistant", "width": 1280, "height": 800, "resizable": true, @@ -36,9 +36,9 @@ ], "resources": [], "externalBin": [], - "copyright": "TFTSR Contributors", + "copyright": "Troubleshooting and RCA Assistant Contributors", "category": "Utility", - "shortDescription": "IT Incident Triage & RCA Tool", - "longDescription": "Structured AI-backed tool for IT incident triage, 5-whys root cause analysis, and post-mortem documentation with offline Ollama support." + "shortDescription": "Troubleshooting and RCA Assistant", + "longDescription": "Structured AI-backed assistant for IT troubleshooting, 5-whys root cause analysis, and post-mortem documentation with offline Ollama support." } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 1f2ba40a..82226fea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,7 +59,7 @@ export default function App() {
{!collapsed && ( - TFTSR + Troubleshooting and RCA Assistant )}
diff --git a/src/pages/Settings/AIProviders.tsx b/src/pages/Settings/AIProviders.tsx index dec32ea1..f4911c34 100644 --- a/src/pages/Settings/AIProviders.tsx +++ b/src/pages/Settings/AIProviders.tsx @@ -19,6 +19,35 @@ import { import { useSettingsStore } from "@/stores/settingsStore"; import { testProviderConnectionCmd, type ProviderConfig } from "@/lib/tauriCommands"; +export const CUSTOM_REST_MODELS = [ + "ChatGPT4o", + "ChatGPT4o-mini", + "ChatGPT-o3-mini", + "Gemini-2_0-Flash-001", + "Gemini-2_5-Flash", + "Claude-Sonnet-3_7", + "Openai-gpt-4_1-mini", + "Openai-o4-mini", + "Claude-Sonnet-4", + "ChatGPT-o3-pro", + "OpenAI-ChatGPT-4_1", + "OpenAI-GPT-4_1-Nano", + "ChatGPT-5", + "VertexGemini", + "ChatGPT-5_1", + "ChatGPT-5_1-chat", + "ChatGPT-5_2-Chat", + "Gemini-3_Pro-Preview", + "Gemini-3_1-flash-lite-preview", +] as const; + +export const CUSTOM_MODEL_OPTION = "__custom_model__"; +export const LEGACY_API_FORMAT = "msi_genai"; +export const CUSTOM_REST_FORMAT = "custom_rest"; + +export const normalizeApiFormat = (format?: string): string | undefined => + format === LEGACY_API_FORMAT ? CUSTOM_REST_FORMAT : format; + const emptyProvider: ProviderConfig = { name: "", provider_type: "openai", @@ -50,19 +79,39 @@ export default function AIProviders() { const [form, setForm] = useState({ ...emptyProvider }); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [isTesting, setIsTesting] = useState(false); + const [isCustomModel, setIsCustomModel] = useState(false); + const [customModelInput, setCustomModelInput] = useState(""); const startAdd = () => { setForm({ ...emptyProvider }); setEditIndex(null); setIsAdding(true); setTestResult(null); + setIsCustomModel(false); + setCustomModelInput(""); }; const startEdit = (index: number) => { - setForm({ ...ai_providers[index] }); + const provider = ai_providers[index]; + const apiFormat = normalizeApiFormat(provider.api_format); + const nextForm = { ...provider, api_format: apiFormat }; + + setForm(nextForm); setEditIndex(index); setIsAdding(true); setTestResult(null); + + const isCustomRestProvider = + nextForm.provider_type === "custom" && apiFormat === CUSTOM_REST_FORMAT; + const knownModel = CUSTOM_REST_MODELS.includes(nextForm.model as (typeof CUSTOM_REST_MODELS)[number]); + + if (isCustomRestProvider && !knownModel) { + setIsCustomModel(true); + setCustomModelInput(nextForm.model); + } else { + setIsCustomModel(false); + setCustomModelInput(""); + } }; const handleSave = () => { @@ -244,11 +293,21 @@ export default function AIProviders() {
- setForm({ ...form, model: e.target.value })} - placeholder="gpt-4o" - /> + {form.provider_type === "custom" + && normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT ? ( + setForm({ ...form, model: e.target.value })} + placeholder="Select API Format below to choose model" + disabled + /> + ) : ( + setForm({ ...form, model: e.target.value })} + placeholder="gpt-4o" + /> + )}
@@ -285,7 +344,7 @@ export default function AIProviders() { onValueChange={(v) => { const format = v; const defaults = - format === "msi_genai" + format === CUSTOM_REST_FORMAT ? { custom_endpoint_path: "", custom_auth_header: "x-msi-genai-api-key", @@ -297,6 +356,10 @@ export default function AIProviders() { custom_auth_prefix: "Bearer ", }; setForm({ ...form, api_format: format, ...defaults }); + if (format !== CUSTOM_REST_FORMAT) { + setIsCustomModel(false); + setCustomModelInput(""); + } }} > @@ -304,11 +367,11 @@ export default function AIProviders() { OpenAI Compatible - MSI GenAI + Custom REST

- Select the API format. MSI GenAI uses a different request/response structure. + Select the API format. Custom REST uses a non-OpenAI request/response structure.

@@ -349,12 +412,12 @@ export default function AIProviders() { placeholder="Bearer " />

- Prefix added before API key (e.g., "Bearer " for OpenAI, empty for MSI GenAI) + Prefix added before API key (e.g., "Bearer " for OpenAI, empty for Custom REST)

- {/* MSI GenAI specific: User ID field */} - {form.api_format === "msi_genai" && ( + {/* Custom REST specific: User ID field */} + {normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT && (
)} + + {/* Custom REST specific: model dropdown with custom option */} + {normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT && ( +
+ + + {isCustomModel && ( + { + const value = e.target.value; + setCustomModelInput(value); + setForm({ ...form, model: value }); + }} + placeholder="Enter custom model ID" + /> + )} +
+ )} )} diff --git a/src/pages/Settings/Integrations.tsx b/src/pages/Settings/Integrations.tsx index 42635506..0fd41a6e 100644 --- a/src/pages/Settings/Integrations.tsx +++ b/src/pages/Settings/Integrations.tsx @@ -453,7 +453,7 @@ export default function Integrations() {

Integrations

- Connect TFTSR with your existing tools and platforms. Choose the authentication method that works best for your environment. + Connect Troubleshooting and RCA Assistant with your existing tools and platforms. Choose the authentication method that works best for your environment.

diff --git a/tests/unit/aiProvidersCustomRest.test.ts b/tests/unit/aiProvidersCustomRest.test.ts new file mode 100644 index 00000000..bc4819f9 --- /dev/null +++ b/tests/unit/aiProvidersCustomRest.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { + CUSTOM_MODEL_OPTION, + CUSTOM_REST_FORMAT, + CUSTOM_REST_MODELS, + LEGACY_API_FORMAT, + normalizeApiFormat, +} from "@/pages/Settings/AIProviders"; + +describe("AIProviders Custom REST helpers", () => { + it("maps legacy msi_genai api_format to custom_rest", () => { + expect(normalizeApiFormat(LEGACY_API_FORMAT)).toBe(CUSTOM_REST_FORMAT); + }); + + it("keeps openai api_format unchanged", () => { + expect(normalizeApiFormat("openai")).toBe("openai"); + }); + + it("contains the guide model list and custom model option sentinel", () => { + expect(CUSTOM_REST_MODELS).toContain("ChatGPT4o"); + expect(CUSTOM_REST_MODELS).toContain("VertexGemini"); + expect(CUSTOM_REST_MODELS).toContain("Gemini-3_Pro-Preview"); + expect(CUSTOM_MODEL_OPTION).toBe("__custom_model__"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 90c8a375..09dca661 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] }, - "types": ["vitest/globals"] + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["src", "tests/unit"], "references": [{ "path": "./tsconfig.node.json" }]