Merge pull request 'fix: resolve clippy uninlined_format_args (CI run 178)' (#2) from fix/clippy-uninlined-format-args into master
Reviewed-on: #2
This commit is contained in:
commit
f2531eb922
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[env]
|
||||
# Force use of system OpenSSL instead of vendored OpenSSL source builds.
|
||||
OPENSSL_NO_VENDOR = "1"
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:**  — 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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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()
|
||||
},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
));
|
||||
|
||||
|
||||
@ -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")
|
||||
));
|
||||
|
||||
|
||||
@ -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<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",
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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("; ")
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
25
tests/unit/aiProvidersCustomRest.test.ts
Normal file
25
tests/unit/aiProvidersCustomRest.test.ts
Normal 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__");
|
||||
});
|
||||
});
|
||||
@ -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" }]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user