Merge pull request 'fix: resolve clippy uninlined_format_args (CI run 178)' (#2) from fix/clippy-uninlined-format-args into master
Some checks failed
Release / build-linux-arm64 (push) Has been cancelled
Release / build-macos-arm64 (push) Has been cancelled
Release / build-linux-amd64 (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled

Reviewed-on: #2
This commit is contained in:
sarman 2026-04-04 21:08:52 +00:00
commit f2531eb922
32 changed files with 465 additions and 211 deletions

3
.cargo/config.toml Normal file
View File

@ -0,0 +1,3 @@
[env]
# Force use of system OpenSSL instead of vendored OpenSSL source builds.
OPENSSL_NO_VENDOR = "1"

View File

@ -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)

View File

@ -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)<br>`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<String>` | Override endpoint path | `/chat/completions` |
| `custom_auth_header` | `Option<String>` | Custom auth header name | `Authorization` |
| `custom_auth_prefix` | `Option<String>` | Prefix before API key | `Bearer ` |
| `api_format` | `Option<String>` | API format (`openai` or `msi_genai`) | `openai` |
| `api_format` | `Option<String>` | API format (`openai` or `custom_rest`) | `openai` |
| `session_id` | `Option<String>` | Session ID for stateful APIs | None |
| `user_id` | `Option<String>` | User ID for cost tracking (MSI GenAI) | None |
| `user_id` | `Option<String>` | 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.

View File

@ -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

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TFTSR — IT Triage & RCA</title>
<title>Troubleshooting and RCA Assistant</title>
</head>
<body>
<div id="root"></div>

View File

@ -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"

View File

@ -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()

View File

@ -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<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
// 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<Message>,
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
})
}
}

View File

@ -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();

View File

@ -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)

View File

@ -295,19 +295,31 @@ pub async fn list_issues(
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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));

View File

@ -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()
},

View File

@ -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<WebviewAuthResponse, String> {
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::<String>, // 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::<String>,
],
)
.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::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to collect integration configs: {}", e))?;
.map_err(|e| format!("Failed to collect integration configs: {e}"))?;
Ok(configs)
}

View File

@ -98,20 +98,26 @@ pub async fn get_audit_log(
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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();

View File

@ -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}");
}
}

View File

@ -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")
));

View File

@ -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")
));

View File

@ -88,7 +88,7 @@ pub async fn exchange_code(
.form(&params)
.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<String, String> {
// 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<String, String> {
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<String, String> {
// 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",

View File

@ -32,7 +32,7 @@ pub async fn test_connection(config: &AzureDevOpsConfig) -> Result<ConnectionRes
.bearer_auth(&config.access_token)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
.map_err(|e| format!("Connection failed: {e}"))?;
if resp.status().is_success() {
Ok(ConnectionResult {
@ -40,9 +40,10 @@ pub async fn test_connection(config: &AzureDevOpsConfig) -> Result<ConnectionRes
message: "Successfully connected to Azure DevOps".to_string(),
})
} else {
let status = resp.status();
Ok(ConnectionResult {
success: false,
message: format!("Connection failed with status: {}", resp.status()),
message: format!("Connection failed with status: {status}"),
})
}
}
@ -61,8 +62,7 @@ pub async fn search_work_items(
// Build WIQL query
let wiql = format!(
"SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State] FROM WorkItems WHERE [System.Title] CONTAINS '{}' ORDER BY [System.CreatedDate] DESC",
query
"SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State] FROM WorkItems WHERE [System.Title] CONTAINS '{query}' ORDER BY [System.CreatedDate] DESC"
);
let body = serde_json::json!({ "query": wiql });
@ -74,7 +74,7 @@ pub async fn search_work_items(
.json(&body)
.send()
.await
.map_err(|e| format!("WIQL query failed: {}", e))?;
.map_err(|e| format!("WIQL query failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -87,7 +87,7 @@ pub async fn search_work_items(
let wiql_result: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse WIQL response: {}", e))?;
.map_err(|e| format!("Failed to parse WIQL response: {e}"))?;
let work_item_refs = wiql_result["workItems"]
.as_array()
@ -119,7 +119,7 @@ pub async fn search_work_items(
.bearer_auth(&config.access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch work item details: {}", e))?;
.map_err(|e| format!("Failed to fetch work item details: {e}"))?;
if !detail_resp.status().is_success() {
return Err(format!(
@ -131,7 +131,7 @@ pub async fn search_work_items(
let details: serde_json::Value = detail_resp
.json()
.await
.map_err(|e| format!("Failed to parse work item details: {}", e))?;
.map_err(|e| format!("Failed to parse work item details: {e}"))?;
let work_items = details["value"]
.as_array()
@ -199,7 +199,7 @@ pub async fn create_work_item(
.json(&operations)
.send()
.await
.map_err(|e| format!("Failed to create work item: {}", e))?;
.map_err(|e| format!("Failed to create work item: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -212,7 +212,7 @@ pub async fn create_work_item(
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 work_item_id = result["id"].as_i64().unwrap_or(0);
let work_item_url = format!(
@ -223,7 +223,7 @@ pub async fn create_work_item(
Ok(TicketResult {
id: work_item_id.to_string(),
ticket_number: format!("#{}", work_item_id),
ticket_number: format!("#{work_item_id}"),
url: work_item_url,
})
}
@ -246,7 +246,7 @@ pub async fn get_work_item(
.bearer_auth(&config.access_token)
.send()
.await
.map_err(|e| format!("Failed to get work item: {}", e))?;
.map_err(|e| format!("Failed to get work item: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -259,7 +259,7 @@ pub async fn get_work_item(
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}"))?;
Ok(WorkItem {
id: result["id"]
@ -305,7 +305,7 @@ pub async fn update_work_item(
.json(&updates)
.send()
.await
.map_err(|e| format!("Failed to update work item: {}", e))?;
.map_err(|e| format!("Failed to update work item: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -318,7 +318,7 @@ pub async fn update_work_item(
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_work_item_id = result["id"].as_i64().unwrap_or(work_item_id);
let work_item_url = format!(
@ -329,7 +329,7 @@ pub async fn update_work_item(
Ok(TicketResult {
id: updated_work_item_id.to_string(),
ticket_number: format!("#{}", updated_work_item_id),
ticket_number: format!("#{updated_work_item_id}"),
url: work_item_url,
})
}

View File

@ -269,7 +269,7 @@ mod tests {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
// Server should be running
let health_url = format!("http://127.0.0.1:{}/health", port);
let health_url = format!("http://127.0.0.1:{port}/health");
let health_before = reqwest::get(&health_url).await;
assert!(health_before.is_ok(), "Server should be running");

View File

@ -35,7 +35,7 @@ pub async fn test_connection(config: &ConfluenceConfig) -> Result<ConnectionResu
.bearer_auth(&config.access_token)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
.map_err(|e| format!("Connection failed: {e}"))?;
if resp.status().is_success() {
Ok(ConnectionResult {
@ -43,9 +43,10 @@ pub async fn test_connection(config: &ConfluenceConfig) -> Result<ConnectionResu
message: "Successfully connected to Confluence".to_string(),
})
} else {
let status = resp.status();
Ok(ConnectionResult {
success: false,
message: format!("Connection failed with status: {}", resp.status()),
message: format!("Connection failed with status: {status}"),
})
}
}
@ -53,7 +54,8 @@ pub async fn test_connection(config: &ConfluenceConfig) -> Result<ConnectionResu
/// List all spaces accessible with the current token
pub async fn list_spaces(config: &ConfluenceConfig) -> Result<Vec<Space>, 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<Vec<Space>, 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<Vec<Space>, 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<PublishResult, String> {
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!(

View File

@ -34,7 +34,7 @@ pub async fn test_connection(config: &ServiceNowConfig) -> Result<ConnectionResu
.query(&[("sysparm_limit", "1")])
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
.map_err(|e| format!("Connection failed: {e}"))?;
if resp.status().is_success() {
Ok(ConnectionResult {
@ -42,9 +42,10 @@ pub async fn test_connection(config: &ServiceNowConfig) -> Result<ConnectionResu
message: "Successfully connected to ServiceNow".to_string(),
})
} else {
let status = resp.status();
Ok(ConnectionResult {
success: false,
message: format!("Connection failed with status: {}", resp.status()),
message: format!("Connection failed with status: {status}"),
})
}
}
@ -60,7 +61,7 @@ pub async fn search_incidents(
config.instance_url.trim_end_matches('/')
);
let sysparm_query = format!("short_descriptionLIKE{}", query);
let sysparm_query = format!("short_descriptionLIKE{query}");
let resp = client
.get(&url)
@ -71,7 +72,7 @@ pub async fn search_incidents(
])
.send()
.await
.map_err(|e| format!("Search failed: {}", e))?;
.map_err(|e| format!("Search failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -84,7 +85,7 @@ pub async fn search_incidents(
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 incidents = body["result"]
.as_array()
@ -134,7 +135,7 @@ pub async fn create_incident(
.json(&body)
.send()
.await
.map_err(|e| format!("Failed to create incident: {}", e))?;
.map_err(|e| format!("Failed to create incident: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -147,7 +148,7 @@ pub async fn create_incident(
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 incident_number = result["result"]["number"].as_str().unwrap_or("");
let sys_id = result["result"]["sys_id"].as_str().unwrap_or("");
@ -198,13 +199,13 @@ pub async fn get_incident(
.basic_auth(&config.username, Some(&config.password));
if use_query {
request = request.query(&[("sysparm_query", &format!("number={}", incident_id))]);
request = request.query(&[("sysparm_query", &format!("number={incident_id}"))]);
}
let resp = request
.send()
.await
.map_err(|e| format!("Failed to get incident: {}", e))?;
.map_err(|e| format!("Failed to get incident: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -217,7 +218,7 @@ pub async fn get_incident(
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 incident_data = if use_query {
// Query response has "result" array
@ -273,7 +274,7 @@ pub async fn update_incident(
.json(&updates)
.send()
.await
.map_err(|e| format!("Failed to update incident: {}", e))?;
.map_err(|e| format!("Failed to update incident: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
@ -286,7 +287,7 @@ pub async fn update_incident(
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 incident_number = result["result"]["number"].as_str().unwrap_or("");
let updated_sys_id = result["result"]["sys_id"].as_str().unwrap_or(sys_id);

View File

@ -25,14 +25,15 @@ pub async fn authenticate_with_webview(
service: &str,
base_url: &str,
) -> Result<ExtractedCredentials, String> {
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<R: tauri::Runtime>(
match serde_json::from_str::<serde_json::Value>(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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
}
}
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<R: tauri::Runtime>(
// 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<R: tauri::Runtime>(
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::<Vec<_>>()
.join("; ")
}

View File

@ -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.

View File

@ -29,14 +29,14 @@ pub struct ProviderConfig {
/// If None, defaults to "Bearer "
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_auth_prefix: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}

View File

@ -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."
}
}

View File

@ -59,7 +59,7 @@ export default function App() {
<div className="flex items-center justify-between px-4 py-4 border-b">
{!collapsed && (
<span className="text-lg font-bold text-foreground tracking-tight">
TFTSR
Troubleshooting and RCA Assistant
</span>
)}
<button

View File

@ -35,7 +35,7 @@ export default function Dashboard() {
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">
IT Triage & Root Cause Analysis
Troubleshooting and Root Cause Analysis Assistant
</p>
</div>
<div className="flex items-center gap-2">

View File

@ -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<ProviderConfig>({ ...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() {
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="gpt-4o"
/>
{form.provider_type === "custom"
&& normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT ? (
<Input
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="Select API Format below to choose model"
disabled
/>
) : (
<Input
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="gpt-4o"
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
@ -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("");
}
}}
>
<SelectTrigger>
@ -304,11 +367,11 @@ export default function AIProviders() {
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI Compatible</SelectItem>
<SelectItem value="msi_genai">MSI GenAI</SelectItem>
<SelectItem value={CUSTOM_REST_FORMAT}>Custom REST</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
@ -349,12 +412,12 @@ export default function AIProviders() {
placeholder="Bearer "
/>
<p className="text-xs text-muted-foreground">
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)
</p>
</div>
{/* MSI GenAI specific: User ID field */}
{form.api_format === "msi_genai" && (
{/* Custom REST specific: User ID field */}
{normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT && (
<div className="space-y-2">
<Label>User ID (CORE ID)</Label>
<Input
@ -367,6 +430,52 @@ export default function AIProviders() {
</p>
</div>
)}
{/* Custom REST specific: model dropdown with custom option */}
{normalizeApiFormat(form.api_format) === CUSTOM_REST_FORMAT && (
<div className="space-y-2">
<Label>Model</Label>
<Select
value={isCustomModel ? CUSTOM_MODEL_OPTION : form.model}
onValueChange={(value) => {
if (value === CUSTOM_MODEL_OPTION) {
setIsCustomModel(true);
if (CUSTOM_REST_MODELS.includes(form.model as (typeof CUSTOM_REST_MODELS)[number])) {
setForm({ ...form, model: "" });
setCustomModelInput("");
}
} else {
setIsCustomModel(false);
setCustomModelInput("");
setForm({ ...form, model: value });
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a model..." />
</SelectTrigger>
<SelectContent>
{CUSTOM_REST_MODELS.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
<SelectItem value={CUSTOM_MODEL_OPTION}>Custom model...</SelectItem>
</SelectContent>
</Select>
{isCustomModel && (
<Input
value={customModelInput}
onChange={(e) => {
const value = e.target.value;
setCustomModelInput(value);
setForm({ ...form, model: value });
}}
placeholder="Enter custom model ID"
/>
)}
</div>
)}
</div>
</>
)}

View File

@ -453,7 +453,7 @@ export default function Integrations() {
<div>
<h1 className="text-3xl font-bold">Integrations</h1>
<p className="text-muted-foreground mt-1">
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.
</p>
</div>

View File

@ -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__");
});
});

View File

@ -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" }]