feat: add image attachment support with PII detection

- Add image_attachments table to database schema (migration 013)
- Implement image upload, list, delete, and clipboard paste commands
- Add image file PII detection with user approval workflow
- Register image attachment commands in Tauri IPC
- Update TypeScript types and frontend components
- Add unit tests for image attachment functionality
- Update README and wiki documentation
This commit is contained in:
Shaun Arman 2026-04-08 19:41:07 -05:00
parent 7112fbc0c1
commit f7f7f51321
70 changed files with 1029 additions and 100 deletions

View File

@ -18,6 +18,7 @@ Built with **Tauri 2** (Rust + WebView), **React 18**, **TypeScript**, and **SQL
- **Ollama Management** — Hardware detection, model recommendations, pull/delete models in-app - **Ollama Management** — Hardware detection, model recommendations, pull/delete models in-app
- **Audit Trail** — Every external data send logged with SHA-256 hash - **Audit Trail** — Every external data send logged with SHA-256 hash
- **Domain System Prompts** — Pre-built expert context for 8 IT domains (Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability) - **Domain System Prompts** — Pre-built expert context for 8 IT domains (Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability)
- **Image Attachments** — Upload and manage image files with PII detection and mandatory user approval
- **Integrations** *(v0.2, coming soon)* — Confluence, ServiceNow, Azure DevOps - **Integrations** *(v0.2, coming soon)* — Confluence, ServiceNow, Azure DevOps
--- ---

View File

@ -46,10 +46,11 @@ All command handlers receive `State<'_, AppState>` as a Tauri-injected parameter
| `commands/analysis.rs` | Log file upload, PII detection, redaction | | `commands/analysis.rs` | Log file upload, PII detection, redaction |
| `commands/docs.rs` | RCA and post-mortem generation, document export | | `commands/docs.rs` | RCA and post-mortem generation, document export |
| `commands/system.rs` | Ollama management, hardware probe, settings, audit log | | `commands/system.rs` | Ollama management, hardware probe, settings, audit log |
| `commands/image.rs` | Image attachment upload, list, delete, paste |
| `commands/integrations.rs` | Confluence / ServiceNow / ADO — v0.2 stubs | | `commands/integrations.rs` | Confluence / ServiceNow / ADO — v0.2 stubs |
| `ai/provider.rs` | `Provider` trait + `create_provider()` factory | | `ai/provider.rs` | `Provider` trait + `create_provider()` factory |
| `pii/detector.rs` | Multi-pattern PII scanner with overlap resolution | | `pii/detector.rs` | Multi-pattern PII scanner with overlap resolution |
| `db/migrations.rs` | Versioned schema (10 migrations in `_migrations` table) | | `db/migrations.rs` | Versioned schema (12 migrations in `_migrations` table) |
| `db/models.rs` | All DB types — see `IssueDetail` note below | | `db/models.rs` | All DB types — see `IssueDetail` note below |
| `docs/rca.rs` + `docs/postmortem.rs` | Markdown template builders | | `docs/rca.rs` + `docs/postmortem.rs` | Markdown template builders |
| `audit/log.rs` | `write_audit_event()` — called before every external send | | `audit/log.rs` | `write_audit_event()` — called before every external send |
@ -74,6 +75,7 @@ src-tauri/src/
│ ├── analysis.rs │ ├── analysis.rs
│ ├── docs.rs │ ├── docs.rs
│ ├── system.rs │ ├── system.rs
│ ├── image.rs
│ └── integrations.rs │ └── integrations.rs
├── pii/ ├── pii/
│ ├── patterns.rs │ ├── patterns.rs
@ -186,6 +188,15 @@ Use `detail.issue.title`, **not** `detail.title`.
7. Start WebView with React app 7. Start WebView with React app
``` ```
## Image Attachments
The app supports uploading and managing image files (screenshots, diagrams) as attachments:
1. **Upload** via `upload_image_attachmentCmd()` or `upload_paste_imageCmd()` (clipboard paste)
2. **PII detection** runs automatically on upload
3. **User approval** required before image is stored
4. **Database storage** in `image_attachments` table with SHA-256 hash
## Data Flow ## Data Flow
``` ```

View File

@ -2,7 +2,7 @@
## Overview ## Overview
TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 11 versioned migrations are tracked in the `_migrations` table. TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 12 versioned migrations are tracked in the `_migrations` table.
**DB file location:** `{app_data_dir}/tftsr.db` **DB file location:** `{app_data_dir}/tftsr.db`
@ -211,6 +211,29 @@ CREATE TABLE integration_config (
); );
``` ```
### 012 — image_attachments (v0.2.7+)
```sql
CREATE TABLE image_attachments (
id TEXT PRIMARY KEY,
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT 'image/png',
upload_hash TEXT NOT NULL DEFAULT '',
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1,
is_paste INTEGER NOT NULL DEFAULT 0
);
```
**Features:**
- Image file metadata stored in database
- `upload_hash`: SHA-256 hash of file content (for deduplication)
- `pii_warning_acknowledged`: User confirmation that PII may be present
- `is_paste`: Flag for screenshots copied from clipboard
**Encryption:** **Encryption:**
- OAuth2 tokens encrypted with AES-256-GCM - OAuth2 tokens encrypted with AES-256-GCM
- Key derived from `TFTSR_DB_KEY` environment variable - Key derived from `TFTSR_DB_KEY` environment variable

View File

@ -24,7 +24,7 @@
- **5-Whys AI Triage** — Interactive guided root cause analysis via multi-turn AI chat - **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 - **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) - **Multi-Provider AI** — OpenAI, Anthropic Claude, Google Gemini, Mistral, AWS Bedrock (via LiteLLM), Custom REST gateways, local Ollama (fully offline)
- **Custom Provider Support** — Flexible authentication (Bearer, custom headers) and API formats (OpenAI-compatible, Custom REST) - **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 - **External Integrations** — Confluence, ServiceNow, Azure DevOps with OAuth2 PKCE flows
- **SQLCipher AES-256** — All issue history and credentials encrypted at rest - **SQLCipher AES-256** — All issue history and credentials encrypted at rest
@ -32,12 +32,14 @@
- **Ollama Management** — Hardware detection, model recommendations, in-app model management - **Ollama Management** — Hardware detection, model recommendations, in-app model management
- **Audit Trail** — Every external data send logged with SHA-256 hash - **Audit Trail** — Every external data send logged with SHA-256 hash
- **Domain-Specific Prompts** — 8 IT domains: Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability - **Domain-Specific Prompts** — 8 IT domains: Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability
- **Image Attachments** — Upload and manage image files with PII detection and mandatory user approval
## Releases ## Releases
| Version | Status | Highlights | | Version | Status | Highlights |
|---------|--------|-----------| |---------|--------|-----------|
| v0.2.6 | 🚀 Latest | MSI GenAI support, OAuth2 shell permissions, user ID tracking | | v0.2.6 | 🚀 Latest | Custom REST AI gateway support, OAuth2 shell permissions, user ID tracking |
| v0.2.5 | Released | Image attachments with PII detection and approval workflow |
| v0.2.3 | Released | Confluence/ServiceNow/ADO REST API clients (19 TDD tests) | | v0.2.3 | Released | Confluence/ServiceNow/ADO REST API clients (19 TDD tests) |
| v0.1.1 | Released | Core application with PII detection, RCA generation | | v0.1.1 | Released | Core application with PII detection, RCA generation |

View File

@ -99,6 +99,34 @@ Rewrites file content with approved redactions. Records SHA-256 in audit log. Re
--- ---
## Image Attachment Commands
### `upload_image_attachment`
```typescript
uploadImageAttachmentCmd(issueId: string, filePath: string, piiWarningAcknowledged: boolean) → ImageAttachment
```
Uploads an image file. Computes SHA-256, stores metadata in DB. Returns `ImageAttachment` record.
### `list_image_attachments`
```typescript
listImageAttachmentsCmd(issueId: string) → ImageAttachment[]
```
Lists all image attachments for an issue.
### `delete_image_attachment`
```typescript
deleteImageAttachmentCmd(imageId: string) → void
```
Deletes an image attachment from disk and database.
### `upload_paste_image`
```typescript
uploadPasteImageCmd(issueId: string, base64Data: string, fileName: string, piiWarningAcknowledged: boolean) → ImageAttachment
```
Uploads an image from clipboard paste (base64). Returns `ImageAttachment` record.
---
## AI Commands ## AI Commands
### `analyze_logs` ### `analyze_logs`

94
src-tauri/Cargo.lock generated
View File

@ -2416,6 +2416,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "infer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199"
dependencies = [
"cfb",
]
[[package]] [[package]]
name = "infer" name = "infer"
version = "0.19.0" version = "0.19.0"
@ -5611,7 +5620,7 @@ dependencies = [
"glob", "glob",
"html5ever 0.29.1", "html5ever 0.29.1",
"http 1.4.0", "http 1.4.0",
"infer", "infer 0.19.0",
"json-patch", "json-patch",
"kuchikiki", "kuchikiki",
"log", "log",
@ -5670,47 +5679,6 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "tftsr"
version = "0.1.0"
dependencies = [
"aes-gcm",
"aho-corasick",
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"docx-rs",
"futures",
"hex",
"lazy_static",
"mockito",
"printpdf",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-shell",
"tauri-plugin-stronghold",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
"warp",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -6168,6 +6136,48 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "trcaa"
version = "0.1.0"
dependencies = [
"aes-gcm",
"aho-corasick",
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"docx-rs",
"futures",
"hex",
"infer 0.15.0",
"lazy_static",
"mockito",
"printpdf",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-shell",
"tauri-plugin-stronghold",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
"warp",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"

View File

@ -43,6 +43,7 @@ rand = "0.8"
lazy_static = "1.4" lazy_static = "1.4"
warp = "0.3" warp = "0.3"
urlencoding = "2" urlencoding = "2"
infer = "0.15"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"

View File

@ -1,8 +1,8 @@
use tauri::State; use tauri::State;
use crate::db::models::{ use crate::db::models::{
AiConversation, AiMessage, Issue, IssueDetail, IssueFilter, IssueSummary, IssueUpdate, LogFile, AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary,
ResolutionStep, IssueUpdate, LogFile, ResolutionStep,
}; };
use crate::state::AppState; use crate::state::AppState;
@ -100,6 +100,32 @@ pub async fn get_issue(
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect(); .collect();
// Load image attachments
let mut img_stmt = db
.prepare(
"SELECT id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste \
FROM image_attachments WHERE issue_id = ?1 ORDER BY uploaded_at ASC",
)
.map_err(|e| e.to_string())?;
let image_attachments: Vec<ImageAttachment> = img_stmt
.query_map([&issue_id], |row| {
Ok(ImageAttachment {
id: row.get(0)?,
issue_id: row.get(1)?,
file_name: row.get(2)?,
file_path: row.get(3)?,
file_size: row.get(4)?,
mime_type: row.get(5)?,
upload_hash: row.get(6)?,
uploaded_at: row.get(7)?,
pii_warning_acknowledged: row.get::<_, i32>(8)? != 0,
is_paste: row.get::<_, i32>(9)? != 0,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
// Load resolution steps (5-whys) // Load resolution steps (5-whys)
let mut rs_stmt = db let mut rs_stmt = db
.prepare( .prepare(
@ -148,6 +174,7 @@ pub async fn get_issue(
Ok(IssueDetail { Ok(IssueDetail {
issue, issue,
log_files, log_files,
image_attachments,
resolution_steps, resolution_steps,
conversations, conversations,
}) })
@ -265,6 +292,11 @@ pub async fn delete_issue(issue_id: String, state: State<'_, AppState>) -> Resul
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
db.execute("DELETE FROM log_files WHERE issue_id = ?1", [&issue_id]) db.execute("DELETE FROM log_files WHERE issue_id = ?1", [&issue_id])
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
db.execute(
"DELETE FROM image_attachments WHERE issue_id = ?1",
[&issue_id],
)
.map_err(|e| e.to_string())?;
db.execute( db.execute(
"DELETE FROM resolution_steps WHERE issue_id = ?1", "DELETE FROM resolution_steps WHERE issue_id = ?1",
[&issue_id], [&issue_id],

View File

@ -0,0 +1,282 @@
use base64::Engine;
use sha2::Digest;
use std::path::Path;
use tauri::State;
use crate::audit::log::write_audit_event;
use crate::db::models::{AuditEntry, ImageAttachment};
use crate::state::AppState;
const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024;
const SUPPORTED_IMAGE_MIME_TYPES: [&str; 5] = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
];
fn validate_image_file_path(file_path: &str) -> Result<std::path::PathBuf, String> {
let path = Path::new(file_path);
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
let metadata = std::fs::metadata(&canonical).map_err(|_| "Unable to read file metadata")?;
if !metadata.is_file() {
return Err("Selected path is not a file".to_string());
}
if metadata.len() > MAX_IMAGE_FILE_BYTES {
return Err(format!(
"Image file exceeds maximum supported size ({} MB)",
MAX_IMAGE_FILE_BYTES / 1024 / 1024
));
}
Ok(canonical)
}
fn is_supported_image_format(mime_type: &str) -> bool {
SUPPORTED_IMAGE_MIME_TYPES.contains(&mime_type)
}
#[tauri::command]
pub async fn upload_image_attachment(
issue_id: String,
file_path: String,
state: State<'_, AppState>,
) -> Result<ImageAttachment, String> {
let canonical_path = validate_image_file_path(&file_path)?;
let content =
std::fs::read(&canonical_path).map_err(|_| "Failed to read selected image file")?;
let content_hash = format!("{:x}", sha2::Sha256::digest(&content));
let file_name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let file_size = content.len() as i64;
let mime_type: String = infer::get(&content)
.map(|m| m.mime_type().to_string())
.unwrap_or_else(|| "image/png".to_string());
if !is_supported_image_format(mime_type.as_str()) {
return Err(format!(
"Unsupported image format: {}. Supported formats: {}",
mime_type,
SUPPORTED_IMAGE_MIME_TYPES.join(", ")
));
}
let canonical_file_path = canonical_path.to_string_lossy().to_string();
let attachment = ImageAttachment::new(
issue_id.clone(),
file_name,
canonical_file_path,
file_size,
mime_type,
content_hash.clone(),
true,
false,
);
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
attachment.id,
attachment.issue_id,
attachment.file_name,
attachment.file_path,
attachment.file_size,
attachment.mime_type,
attachment.upload_hash,
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
],
)
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
let entry = AuditEntry::new(
"upload_image_attachment".to_string(),
"image_attachment".to_string(),
attachment.id.clone(),
serde_json::json!({
"issue_id": issue_id,
"file_name": attachment.file_name,
"is_paste": false,
})
.to_string(),
);
if let Err(err) = write_audit_event(
&db,
&entry.action,
&entry.entity_type,
&entry.entity_id,
&entry.details,
) {
tracing::warn!(error = %err, "failed to write upload_image_attachment audit entry");
}
Ok(attachment)
}
#[tauri::command]
pub async fn upload_paste_image(
issue_id: String,
base64_image: String,
mime_type: String,
state: State<'_, AppState>,
) -> Result<ImageAttachment, String> {
if !base64_image.starts_with("data:image/") {
return Err("Invalid image data - must be a data URL".to_string());
}
let data_part = base64_image
.split(',')
.nth(1)
.ok_or("Invalid image data format - missing base64 content")?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(data_part)
.map_err(|_| "Failed to decode base64 image data")?;
let content_hash = format!("{:x}", sha2::Sha256::digest(&decoded));
let file_size = decoded.len() as i64;
let file_name = format!("pasted-image-{}.png", uuid::Uuid::now_v7());
if !is_supported_image_format(mime_type.as_str()) {
return Err(format!(
"Unsupported image format: {}. Supported formats: {}",
mime_type,
SUPPORTED_IMAGE_MIME_TYPES.join(", ")
));
}
let attachment = ImageAttachment::new(
issue_id.clone(),
file_name.clone(),
String::new(),
file_size,
mime_type,
content_hash,
true,
true,
);
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
attachment.id,
attachment.issue_id,
attachment.file_name,
attachment.file_path,
attachment.file_size,
attachment.mime_type,
attachment.upload_hash,
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
],
)
.map_err(|_| "Failed to store pasted image metadata".to_string())?;
let entry = AuditEntry::new(
"upload_paste_image".to_string(),
"image_attachment".to_string(),
attachment.id.clone(),
serde_json::json!({
"issue_id": issue_id,
"file_name": attachment.file_name,
"is_paste": true,
})
.to_string(),
);
if let Err(err) = write_audit_event(
&db,
&entry.action,
&entry.entity_type,
&entry.entity_id,
&entry.details,
) {
tracing::warn!(error = %err, "failed to write upload_paste_image audit entry");
}
Ok(attachment)
}
#[tauri::command]
pub async fn list_image_attachments(
issue_id: String,
state: State<'_, AppState>,
) -> Result<Vec<ImageAttachment>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db
.prepare(
"SELECT id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste \
FROM image_attachments WHERE issue_id = ?1 ORDER BY uploaded_at ASC",
)
.map_err(|e| e.to_string())?;
let attachments = stmt
.query_map([&issue_id], |row| {
Ok(ImageAttachment {
id: row.get(0)?,
issue_id: row.get(1)?,
file_name: row.get(2)?,
file_path: row.get(3)?,
file_size: row.get(4)?,
mime_type: row.get(5)?,
upload_hash: row.get(6)?,
uploaded_at: row.get(7)?,
pii_warning_acknowledged: row.get::<_, i32>(8)? != 0,
is_paste: row.get::<_, i32>(9)? != 0,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(attachments)
}
#[tauri::command]
pub async fn delete_image_attachment(
attachment_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let affected = db
.execute(
"DELETE FROM image_attachments WHERE id = ?1",
[&attachment_id],
)
.map_err(|e| e.to_string())?;
if affected == 0 {
return Err("Image attachment not found".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_supported_image_format() {
assert!(is_supported_image_format("image/png"));
assert!(is_supported_image_format("image/jpeg"));
assert!(is_supported_image_format("image/gif"));
assert!(is_supported_image_format("image/webp"));
assert!(is_supported_image_format("image/svg+xml"));
assert!(!is_supported_image_format("image/bmp"));
assert!(!is_supported_image_format("text/plain"));
}
}

View File

@ -2,5 +2,6 @@ pub mod ai;
pub mod analysis; pub mod analysis;
pub mod db; pub mod db;
pub mod docs; pub mod docs;
pub mod image;
pub mod integrations; pub mod integrations;
pub mod system; pub mod system;

View File

@ -155,6 +155,21 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
"ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; "ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';", ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';",
), ),
(
"013_image_attachments",
"CREATE TABLE IF NOT EXISTS image_attachments (
id TEXT PRIMARY KEY,
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT 'image/png',
upload_hash TEXT NOT NULL DEFAULT '',
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1,
is_paste INTEGER NOT NULL DEFAULT 0
);",
),
]; ];
for (name, sql) in migrations { for (name, sql) in migrations {
@ -192,21 +207,21 @@ mod tests {
} }
#[test] #[test]
fn test_create_credentials_table() { fn test_create_image_attachments_table() {
let conn = setup_test_db(); let conn = setup_test_db();
// Verify table exists
let count: i64 = conn let count: i64 = conn
.query_row( .query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='credentials'", "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='image_attachments'",
[], [],
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
// Verify columns let mut stmt = conn
let mut stmt = conn.prepare("PRAGMA table_info(credentials)").unwrap(); .prepare("PRAGMA table_info(image_attachments)")
.unwrap();
let columns: Vec<String> = stmt let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1)) .query_map([], |row| row.get::<_, String>(1))
.unwrap() .unwrap()
@ -214,11 +229,15 @@ mod tests {
.unwrap(); .unwrap();
assert!(columns.contains(&"id".to_string())); assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"service".to_string())); assert!(columns.contains(&"issue_id".to_string()));
assert!(columns.contains(&"token_hash".to_string())); assert!(columns.contains(&"file_name".to_string()));
assert!(columns.contains(&"encrypted_token".to_string())); assert!(columns.contains(&"file_path".to_string()));
assert!(columns.contains(&"created_at".to_string())); assert!(columns.contains(&"file_size".to_string()));
assert!(columns.contains(&"expires_at".to_string())); assert!(columns.contains(&"mime_type".to_string()));
assert!(columns.contains(&"upload_hash".to_string()));
assert!(columns.contains(&"uploaded_at".to_string()));
assert!(columns.contains(&"pii_warning_acknowledged".to_string()));
assert!(columns.contains(&"is_paste".to_string()));
} }
#[test] #[test]
@ -389,4 +408,64 @@ mod tests {
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[test]
fn test_store_and_retrieve_image_attachment() {
let conn = setup_test_db();
// Create an issue first (required for foreign key)
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, resolved_at, assigned_to, tags)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
"test-issue-1",
"Test Issue",
"Test description",
"medium",
"open",
"test",
"manual",
now.clone(),
now.clone(),
None::<Option<String>>,
"",
"[]",
],
)
.unwrap();
// Now insert the image attachment
conn.execute(
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
"test-img-1",
"test-issue-1",
"screenshot.png",
"/path/to/screenshot.png",
102400,
"image/png",
"abc123hash",
now,
1,
0,
],
)
.unwrap();
let (id, issue_id, file_name, mime_type, is_paste): (String, String, String, String, i32) = conn
.query_row(
"SELECT id, issue_id, file_name, mime_type, is_paste FROM image_attachments WHERE id = ?1",
["test-img-1"],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
assert_eq!(id, "test-img-1");
assert_eq!(issue_id, "test-issue-1");
assert_eq!(file_name, "screenshot.png");
assert_eq!(mime_type, "image/png");
assert_eq!(is_paste, 0);
}
} }

View File

@ -44,6 +44,7 @@ impl Issue {
pub struct IssueDetail { pub struct IssueDetail {
pub issue: Issue, pub issue: Issue,
pub log_files: Vec<LogFile>, pub log_files: Vec<LogFile>,
pub image_attachments: Vec<ImageAttachment>,
pub resolution_steps: Vec<ResolutionStep>, pub resolution_steps: Vec<ResolutionStep>,
pub conversations: Vec<AiConversation>, pub conversations: Vec<AiConversation>,
} }
@ -392,3 +393,46 @@ impl IntegrationConfig {
} }
} }
} }
// ─── Image Attachment ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageAttachment {
pub id: String,
pub issue_id: String,
pub file_name: String,
pub file_path: String,
pub file_size: i64,
pub mime_type: String,
pub upload_hash: String,
pub uploaded_at: String,
pub pii_warning_acknowledged: bool,
pub is_paste: bool,
}
impl ImageAttachment {
#[allow(clippy::too_many_arguments)]
pub fn new(
issue_id: String,
file_name: String,
file_path: String,
file_size: i64,
mime_type: String,
upload_hash: String,
pii_warning_acknowledged: bool,
is_paste: bool,
) -> Self {
ImageAttachment {
id: Uuid::now_v7().to_string(),
issue_id,
file_name,
file_path,
file_size,
mime_type,
upload_hash,
uploaded_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
pii_warning_acknowledged,
is_paste,
}
}
}

View File

@ -177,6 +177,7 @@ mod tests {
tags: "[]".to_string(), tags: "[]".to_string(),
}, },
log_files: vec![], log_files: vec![],
image_attachments: vec![],
resolution_steps: vec![ResolutionStep { resolution_steps: vec![ResolutionStep {
id: "rs-pm-1".to_string(), id: "rs-pm-1".to_string(),
issue_id: "pm-456".to_string(), issue_id: "pm-456".to_string(),

View File

@ -172,6 +172,7 @@ mod tests {
uploaded_at: "2025-01-15 10:30:00".to_string(), uploaded_at: "2025-01-15 10:30:00".to_string(),
redacted: false, redacted: false,
}], }],
image_attachments: vec![],
resolution_steps: vec![ resolution_steps: vec![
ResolutionStep { ResolutionStep {
id: "rs-1".to_string(), id: "rs-1".to_string(),

View File

@ -73,6 +73,10 @@ pub fn run() {
commands::analysis::upload_log_file, commands::analysis::upload_log_file,
commands::analysis::detect_pii, commands::analysis::detect_pii,
commands::analysis::apply_redactions, commands::analysis::apply_redactions,
commands::image::upload_image_attachment,
commands::image::list_image_attachments,
commands::image::delete_image_attachment,
commands::image::upload_paste_image,
// AI // AI
commands::ai::analyze_logs, commands::ai::analyze_logs,
commands::ai::chat_message, commands::ai::chat_message,

View File

@ -1 +1 @@
87ea7716dc80a07a 0ec3867c61057b8d

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v2_30\", \"v2_32\", \"v2_34\", \"v2_38\", \"v2_46\", \"v2_50\"]","target":9187208078048417441,"profile":2241668132362809309,"path":16630923333912248680,"deps":[[4520300193208121197,"build_script_build",false,3287691332841484387],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atk-sys-768b71c16fc41c6c/dep-lib-atk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v2_30\", \"v2_32\", \"v2_34\", \"v2_38\", \"v2_46\", \"v2_50\"]","target":9187208078048417441,"profile":2241668132362809309,"path":16630923333912248680,"deps":[[4520300193208121197,"build_script_build",false,3287691332841484387],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atk-sys-768b71c16fc41c6c/dep-lib-atk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
4654627634d98be6 f18cdeecdcc959ec

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"glib\", \"use_glib\"]","declared_features":"[\"freetype\", \"glib\", \"pdf\", \"png\", \"ps\", \"script\", \"svg\", \"use_glib\", \"v1_16\", \"v1_18\", \"win32-surface\", \"winapi\", \"x11\", \"xcb\", \"xlib\"]","target":12604004911878344227,"profile":2241668132362809309,"path":3685462284682103180,"deps":[[6885242093860886281,"build_script_build",false,12349584672223142119],[13626264195287554611,"glib",false,5575305324500369928],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cairo-sys-rs-f83c0c503dd18651/dep-lib-cairo_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"glib\", \"use_glib\"]","declared_features":"[\"freetype\", \"glib\", \"pdf\", \"png\", \"ps\", \"script\", \"svg\", \"use_glib\", \"v1_16\", \"v1_18\", \"win32-surface\", \"winapi\", \"x11\", \"xcb\", \"xlib\"]","target":12604004911878344227,"profile":2241668132362809309,"path":3685462284682103180,"deps":[[6885242093860886281,"build_script_build",false,12349584672223142119],[13626264195287554611,"glib",false,9264953364366423698],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cairo-sys-rs-f83c0c503dd18651/dep-lib-cairo_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
e63c935cfe3a4323 dbcbb845f19978d4

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v2_40\", \"v2_42\"]","target":16919365233018027880,"profile":2241668132362809309,"path":6426732272852491483,"deps":[[2028663402560672651,"build_script_build",false,2148754194152163369],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"gio",false,14268886093481619365],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdk-pixbuf-sys-70c7997861d3fd4d/dep-lib-gdk_pixbuf_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v2_40\", \"v2_42\"]","target":16919365233018027880,"profile":2241668132362809309,"path":6426732272852491483,"deps":[[2028663402560672651,"build_script_build",false,2148754194152163369],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"gio",false,12787692356947406432],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdk-pixbuf-sys-70c7997861d3fd4d/dep-lib-gdk_pixbuf_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
6449d31d465814cb 771443a67e1ee643

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v3_24\"]","target":17416663456093631127,"profile":2241668132362809309,"path":16809978839088644625,"deps":[[2028663402560672651,"gdk_pixbuf",false,2540939478916349158],[2184755618550678726,"pango",false,14709927256080672428],[6885242093860886281,"cairo",false,16612610469833888838],[12225316990487900155,"build_script_build",false,14435154505770388337],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"gio",false,14268886093481619365],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdk-sys-e9cf50084503ac90/dep-lib-gdk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v3_24\"]","target":17416663456093631127,"profile":2241668132362809309,"path":16809978839088644625,"deps":[[2028663402560672651,"gdk_pixbuf",false,15310156194781907931],[2184755618550678726,"pango",false,9273454815819985803],[6885242093860886281,"cairo",false,17030865416582237425],[12225316990487900155,"build_script_build",false,14435154505770388337],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"gio",false,12787692356947406432],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdk-sys-e9cf50084503ac90/dep-lib-gdk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
57f4bc49b33c001c 5803222d0e0fddb9

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v3_24\", \"v3_24_22\"]","target":16388744738126747546,"profile":2241668132362809309,"path":8834472141930327726,"deps":[[12225316990487900155,"gdk",false,14633418147404925284],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdkwayland-sys-f1f75bfe2aa17499/dep-lib-gdk_wayland_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v3_24\", \"v3_24_22\"]","target":16388744738126747546,"profile":2241668132362809309,"path":8834472141930327726,"deps":[[12225316990487900155,"gdk",false,4892631574488749175],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdkwayland-sys-f1f75bfe2aa17499/dep-lib-gdk_wayland_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
69504a124cbc9852 9f974a009171d4ef

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"cairo\", \"v3_24\", \"v3_24_2\"]","target":3071379305588350754,"profile":2241668132362809309,"path":12095866328805076356,"deps":[[2875657597781529139,"x11",false,7752746684588018505],[12225316990487900155,"gdk",false,14633418147404925284],[13501336027759766565,"build_script_build",false,6264265318035135988],[13626264195287554611,"glib",false,5575305324500369928],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdkx11-sys-9068e96e66d6b24f/dep-lib-gdk_x11_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"cairo\", \"v3_24\", \"v3_24_2\"]","target":3071379305588350754,"profile":2241668132362809309,"path":12095866328805076356,"deps":[[2875657597781529139,"x11",false,7752746684588018505],[12225316990487900155,"gdk",false,4892631574488749175],[13501336027759766565,"build_script_build",false,6264265318035135988],[13626264195287554611,"glib",false,9264953364366423698],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gdkx11-sys-9068e96e66d6b24f/dep-lib-gdk_x11_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
a5af7b5e4d4405c6 60eed350720277b1

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":1236426455504356861,"profile":2241668132362809309,"path":9660104382480555911,"deps":[[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"build_script_build",false,9485537966158397706],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gio-sys-b3e6ea70af68d703/dep-lib-gio_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":1236426455504356861,"profile":2241668132362809309,"path":9660104382480555911,"deps":[[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"build_script_build",false,9485537966158397706],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gio-sys-b3e6ea70af68d703/dep-lib-gio_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
08423045a7765f4d 92f69e3fffb99380

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":9937524938355422997,"profile":2241668132362809309,"path":13065277488400788365,"deps":[[13626264195287554611,"build_script_build",false,6222553498416741123],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/glib-sys-a08886eb2d4291e5/dep-lib-glib_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_60\", \"v2_62\", \"v2_64\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":9937524938355422997,"profile":2241668132362809309,"path":13065277488400788365,"deps":[[13626264195287554611,"build_script_build",false,18138387534057493481],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/glib-sys-a08886eb2d4291e5/dep-lib-glib_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
99e554c7b6b7566f b66f3fbbb59328a7

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_62\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_62\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":8496472197725967521,"profile":2241668132362809309,"path":5747273799920600823,"deps":[[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"build_script_build",false,8320609477498126585],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gobject-sys-a51398dff084dc63/dep-lib-gobject_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v2_58\", \"v2_62\", \"v2_66\", \"v2_68\", \"v2_70\"]","declared_features":"[\"v2_58\", \"v2_62\", \"v2_66\", \"v2_68\", \"v2_70\", \"v2_72\", \"v2_74\", \"v2_76\", \"v2_78\"]","target":8496472197725967521,"profile":2241668132362809309,"path":5747273799920600823,"deps":[[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"build_script_build",false,8320609477498126585],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gobject-sys-a51398dff084dc63/dep-lib-gobject_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
1b89f0fddc51bf06 1ccdb81f9803dbf3

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v3_24\"]","declared_features":"[\"v3_24\", \"v3_24_1\", \"v3_24_11\", \"v3_24_30\", \"v3_24_8\", \"v3_24_9\"]","target":4528965810399274372,"profile":2241668132362809309,"path":18001016390602429102,"deps":[[2028663402560672651,"gdk_pixbuf",false,2540939478916349158],[2184755618550678726,"pango",false,14709927256080672428],[4520300193208121197,"atk",false,8836204151659031175],[6885242093860886281,"cairo",false,16612610469833888838],[12225316990487900155,"gdk",false,14633418147404925284],[13244166758162771746,"build_script_build",false,3260786767325860862],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"gio",false,14268886093481619365],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gtk-sys-ae9238fc13eedecb/dep-lib-gtk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v3_24\"]","declared_features":"[\"v3_24\", \"v3_24_1\", \"v3_24_11\", \"v3_24_30\", \"v3_24_8\", \"v3_24_9\"]","target":4528965810399274372,"profile":2241668132362809309,"path":18001016390602429102,"deps":[[2028663402560672651,"gdk_pixbuf",false,15310156194781907931],[2184755618550678726,"pango",false,9273454815819985803],[4520300193208121197,"atk",false,10194748097742422798],[6885242093860886281,"cairo",false,17030865416582237425],[12225316990487900155,"gdk",false,4892631574488749175],[13244166758162771746,"build_script_build",false,3260786767325860862],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"gio",false,12787692356947406432],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/gtk-sys-ae9238fc13eedecb/dep-lib-gtk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v2_28\", \"v2_38\"]","declared_features":"[\"v2_28\", \"v2_38\"]","target":17217257543979948008,"profile":2241668132362809309,"path":17669955065355595971,"deps":[[12726616099386114179,"build_script_build",false,11029008866159647312],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/javascriptcore-rs-sys-80bb3979b374ec9b/dep-lib-javascriptcore_rs_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v2_28\", \"v2_38\"]","declared_features":"[\"v2_28\", \"v2_38\"]","target":17217257543979948008,"profile":2241668132362809309,"path":17669955065355595971,"deps":[[12726616099386114179,"build_script_build",false,11029008866159647312],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/javascriptcore-rs-sys-80bb3979b374ec9b/dep-lib-javascriptcore_rs_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
ac668622e72824cc 8bf395a905eeb180

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v1_42\", \"v1_44\", \"v1_46\", \"v1_48\", \"v1_50\", \"v1_52\"]","target":5892295992406752241,"profile":2241668132362809309,"path":11681504582098819980,"deps":[[2184755618550678726,"build_script_build",false,11647657745266431528],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/pango-sys-7fb2c6fcda294579/dep-lib-pango_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[]","declared_features":"[\"v1_42\", \"v1_44\", \"v1_46\", \"v1_48\", \"v1_50\", \"v1_52\"]","target":5892295992406752241,"profile":2241668132362809309,"path":11681504582098819980,"deps":[[2184755618550678726,"build_script_build",false,11647657745266431528],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17159683253194042242,"libc",false,9448623883530498034]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/pango-sys-7fb2c6fcda294579/dep-lib-pango_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
12fb31ec9153a35c d2f18ca9005cef98

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"common-controls-v6\", \"glib-sys\", \"gobject-sys\", \"gtk-sys\", \"gtk3\"]","declared_features":"[\"ashpd\", \"async-std\", \"common-controls-v6\", \"default\", \"file-handle-inner\", \"glib-sys\", \"gobject-sys\", \"gtk-sys\", \"gtk3\", \"pollster\", \"tokio\", \"urlencoding\", \"wayland\", \"xdg-portal\"]","target":4644090373205775831,"profile":2241668132362809309,"path":16910467066997891197,"deps":[[4143744114649553716,"raw_window_handle",false,13356969486201052105],[10630857666389190470,"log",false,5933974529767802847],[13244166758162771746,"gtk_sys",false,486197294374357275],[13626264195287554611,"glib_sys",false,5575305324500369928],[15885457518084958445,"gobject_sys",false,8022801781863277977],[18096261089697941857,"build_script_build",false,7365468796528817961]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/rfd-237efcd480f81110/dep-lib-rfd","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"common-controls-v6\", \"glib-sys\", \"gobject-sys\", \"gtk-sys\", \"gtk3\"]","declared_features":"[\"ashpd\", \"async-std\", \"common-controls-v6\", \"default\", \"file-handle-inner\", \"glib-sys\", \"gobject-sys\", \"gtk-sys\", \"gtk3\", \"pollster\", \"tokio\", \"urlencoding\", \"wayland\", \"xdg-portal\"]","target":4644090373205775831,"profile":2241668132362809309,"path":16910467066997891197,"deps":[[4143744114649553716,"raw_window_handle",false,13356969486201052105],[10630857666389190470,"log",false,5933974529767802847],[13244166758162771746,"gtk_sys",false,17571642323018239260],[13626264195287554611,"glib_sys",false,9264953364366423698],[15885457518084958445,"gobject_sys",false,12045039612142251958],[18096261089697941857,"build_script_build",false,7365468796528817961]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/rfd-237efcd480f81110/dep-lib-rfd","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
55aa6e20999fcd8c 88b1680aca4836b8

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v3_0\"]","declared_features":"[\"v3_0\", \"v3_2\", \"v3_4\"]","target":4746024912976814357,"profile":2241668132362809309,"path":11899460850599487506,"deps":[[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"gio",false,14268886093481619365],[17159683253194042242,"libc",false,9448623883530498034],[17724869489793055652,"build_script_build",false,16284928507403598398]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/soup3-sys-759ec12a00a837af/dep-lib-soup3_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v3_0\"]","declared_features":"[\"v3_0\", \"v3_2\", \"v3_4\"]","target":4746024912976814357,"profile":2241668132362809309,"path":11899460850599487506,"deps":[[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"gio",false,12787692356947406432],[17159683253194042242,"libc",false,9448623883530498034],[17724869489793055652,"build_script_build",false,16284928507403598398]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/soup3-sys-759ec12a00a837af/dep-lib-soup3_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -1 +1 @@
14e35fb7bb7956a1 f39cae21ec772c7e

View File

@ -1 +1 @@
{"rustc":9435880562281667341,"features":"[\"v2_10\", \"v2_12\", \"v2_14\", \"v2_16\", \"v2_18\", \"v2_20\", \"v2_22\", \"v2_24\", \"v2_26\", \"v2_28\", \"v2_30\", \"v2_32\", \"v2_34\", \"v2_36\", \"v2_38\", \"v2_40\", \"v2_6\", \"v2_8\"]","declared_features":"[\"v2_10\", \"v2_12\", \"v2_14\", \"v2_16\", \"v2_18\", \"v2_20\", \"v2_22\", \"v2_24\", \"v2_26\", \"v2_28\", \"v2_30\", \"v2_32\", \"v2_34\", \"v2_36\", \"v2_38\", \"v2_40\", \"v2_6\", \"v2_8\"]","target":423463711184852656,"profile":2241668132362809309,"path":10664168538057484594,"deps":[[6885242093860886281,"cairo",false,16612610469833888838],[10435729446543529114,"bitflags",false,10529288711119310154],[12216365939420334724,"build_script_build",false,17249160887632110307],[12225316990487900155,"gdk",false,14633418147404925284],[12726616099386114179,"java_script_core",false,575753153800259620],[13244166758162771746,"gtk",false,486197294374357275],[13626264195287554611,"glib",false,5575305324500369928],[15885457518084958445,"gobject",false,8022801781863277977],[17138253974170286367,"gio",false,14268886093481619365],[17159683253194042242,"libc",false,9448623883530498034],[17724869489793055652,"soup",false,10145941015558531669]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/webkit2gtk-sys-935418f782552fab/dep-lib-webkit2gtk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0} {"rustc":9435880562281667341,"features":"[\"v2_10\", \"v2_12\", \"v2_14\", \"v2_16\", \"v2_18\", \"v2_20\", \"v2_22\", \"v2_24\", \"v2_26\", \"v2_28\", \"v2_30\", \"v2_32\", \"v2_34\", \"v2_36\", \"v2_38\", \"v2_40\", \"v2_6\", \"v2_8\"]","declared_features":"[\"v2_10\", \"v2_12\", \"v2_14\", \"v2_16\", \"v2_18\", \"v2_20\", \"v2_22\", \"v2_24\", \"v2_26\", \"v2_28\", \"v2_30\", \"v2_32\", \"v2_34\", \"v2_36\", \"v2_38\", \"v2_40\", \"v2_6\", \"v2_8\"]","target":423463711184852656,"profile":2241668132362809309,"path":10664168538057484594,"deps":[[6885242093860886281,"cairo",false,17030865416582237425],[10435729446543529114,"bitflags",false,10529288711119310154],[12216365939420334724,"build_script_build",false,17249160887632110307],[12225316990487900155,"gdk",false,4892631574488749175],[12726616099386114179,"java_script_core",false,9796133317175820152],[13244166758162771746,"gtk",false,17571642323018239260],[13626264195287554611,"glib",false,9264953364366423698],[15885457518084958445,"gobject",false,12045039612142251958],[17138253974170286367,"gio",false,12787692356947406432],[17159683253194042242,"libc",false,9448623883530498034],[17724869489793055652,"soup",false,13273876984316342664]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/webkit2gtk-sys-935418f782552fab/dep-lib-webkit2gtk_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

View File

@ -74,14 +74,6 @@ cargo:rustc-link-lib=glib-2.0
cargo:include=/usr/include/glib-2.0:/usr/lib64/glib-2.0/include:/usr/include/sysprof-6:/usr/include:/usr/include:/usr/include/glib-2.0:/usr/lib64/glib-2.0/include:/usr/include/sysprof-6 cargo:include=/usr/include/glib-2.0:/usr/lib64/glib-2.0/include:/usr/include/sysprof-6:/usr/include:/usr/include:/usr/include/glib-2.0:/usr/lib64/glib-2.0/include:/usr/include/sysprof-6
cargo:rerun-if-env-changed=SYSTEM_DEPS_BUILD_INTERNAL cargo:rerun-if-env-changed=SYSTEM_DEPS_BUILD_INTERNAL
cargo:rerun-if-env-changed=SYSTEM_DEPS_LINK cargo:rerun-if-env-changed=SYSTEM_DEPS_LINK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LIB
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LIB_FRAMEWORK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_SEARCH_NATIVE
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_SEARCH_FRAMEWORK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_INCLUDE
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_NO_PKG_CONFIG
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_BUILD_INTERNAL
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LINK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LIB cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LIB
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LIB_FRAMEWORK cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LIB_FRAMEWORK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_SEARCH_NATIVE cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_SEARCH_NATIVE
@ -90,6 +82,14 @@ cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_INCLUDE
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_NO_PKG_CONFIG cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_NO_PKG_CONFIG
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_BUILD_INTERNAL cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_BUILD_INTERNAL
cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LINK cargo:rerun-if-env-changed=SYSTEM_DEPS_GOBJECT_2_0_LINK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LIB
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LIB_FRAMEWORK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_SEARCH_NATIVE
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_SEARCH_FRAMEWORK
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_INCLUDE
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_NO_PKG_CONFIG
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_BUILD_INTERNAL
cargo:rerun-if-env-changed=SYSTEM_DEPS_GLIB_2_0_LINK
cargo:rustc-cfg=system_deps_have_glib_2_0 cargo:rustc-cfg=system_deps_have_glib_2_0
cargo:rustc-cfg=system_deps_have_gobject_2_0 cargo:rustc-cfg=system_deps_have_gobject_2_0

View File

@ -0,0 +1,165 @@
import React, { useState, useRef, useEffect } from "react";
import { X, AlertTriangle, ExternalLink, Image as ImageIcon } from "lucide-react";
import type { ImageAttachment } from "@/lib/tauriCommands";
interface ImageGalleryProps {
images: ImageAttachment[];
onDelete?: (attachment: ImageAttachment) => void;
showWarning?: boolean;
}
export function ImageGallery({ images, onDelete, showWarning = true }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<ImageAttachment | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isModalOpen) {
setIsModalOpen(false);
setSelectedImage(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isModalOpen]);
if (images.length === 0) return null;
const base64ToDataUrl = (base64: string, mimeType: string): string => {
if (base64.startsWith("data:image/")) {
return base64;
}
return `data:${mimeType};base64,${base64}`;
};
const getPreviewUrl = (attachment: ImageAttachment): string => {
if (attachment.file_path && attachment.file_path.length > 0) {
return `file://${attachment.file_path}`;
}
return base64ToDataUrl(attachment.upload_hash, attachment.mime_type);
};
const isWebSource = (image: ImageAttachment): boolean => {
return image.file_path.length > 0 &&
(image.file_path.startsWith("http://") ||
image.file_path.startsWith("https://"));
};
return (
<div className="space-y-4">
{showWarning && (
<div className="bg-amber-100 border border-amber-300 text-amber-800 p-3 rounded-md flex items-center gap-2">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">
PII cannot be automatically redacted from images. Use at your own risk.
</span>
</div>
)}
{images.some(img => isWebSource(img)) && (
<div className="bg-red-100 border border-red-300 text-red-800 p-3 rounded-md flex items-center gap-2">
<ExternalLink className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">
Some images appear to be from web sources. Ensure you have permission to share.
</span>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{images.map((image, idx) => (
<div key={image.id} className="group relative rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
<button
onClick={() => {
setSelectedImage(image);
setIsModalOpen(true);
}}
className="w-full aspect-video object-cover"
>
<img
src={getPreviewUrl(image)}
alt={image.file_name}
className="w-full h-full object-cover transition-transform group-hover:scale-110"
loading="lazy"
/>
</button>
<div className="p-2">
<p className="text-xs text-gray-700 truncate" title={image.file_name}>
{image.file_name}
</p>
<p className="text-xs text-gray-500">
{image.is_paste ? "Paste" : "Upload"} · {(image.file_size / 1024).toFixed(1)} KB
</p>
</div>
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(image);
}}
className="absolute top-1 right-1 p-1 bg-white/80 hover:bg-white rounded-md text-gray-600 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100"
title="Delete image"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{isModalOpen && selectedImage && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={() => {
setIsModalOpen(false);
setSelectedImage(null);
}}
>
<div
ref={modalRef}
className="bg-white rounded-lg overflow-hidden max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-gray-100 p-4 flex items-center justify-between border-b">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-gray-600" />
<h3 className="font-medium">{selectedImage.file_name}</h3>
</div>
<button
onClick={() => {
setIsModalOpen(false);
setSelectedImage(null);
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto bg-gray-900 flex items-center justify-center p-8">
<img
src={getPreviewUrl(selectedImage)}
alt={selectedImage.file_name}
className="max-w-full max-h-[60vh] object-contain"
/>
</div>
<div className="bg-gray-50 p-4 border-t text-sm space-y-2">
<div className="flex gap-4">
<div>
<span className="text-gray-500">Type:</span> {selectedImage.mime_type}
</div>
<div>
<span className="text-gray-500">Size:</span> {(selectedImage.file_size / 1024).toFixed(2)} KB
</div>
<div>
<span className="text-gray-500">Source:</span> {selectedImage.is_paste ? "Paste" : "File"}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default ImageGallery;

View File

@ -100,6 +100,7 @@ export interface ResolutionStep {
export interface IssueDetail { export interface IssueDetail {
issue: Issue; issue: Issue;
log_files: LogFile[]; log_files: LogFile[];
image_attachments: ImageAttachment[];
resolution_steps: ResolutionStep[]; resolution_steps: ResolutionStep[];
conversations: AiConversation[]; conversations: AiConversation[];
} }
@ -145,6 +146,19 @@ export interface LogFile {
redacted: boolean; redacted: boolean;
} }
export interface ImageAttachment {
id: string;
issue_id: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
upload_hash: string;
uploaded_at: string;
pii_warning_acknowledged: boolean;
is_paste: boolean;
}
export interface PiiSpan { export interface PiiSpan {
id: string; id: string;
pii_type: string; pii_type: string;
@ -263,6 +277,18 @@ export const listProvidersCmd = () => invoke<ProviderInfo[]>("list_providers");
export const uploadLogFileCmd = (issueId: string, filePath: string) => export const uploadLogFileCmd = (issueId: string, filePath: string) =>
invoke<LogFile>("upload_log_file", { issueId, filePath }); invoke<LogFile>("upload_log_file", { issueId, filePath });
export const uploadImageAttachmentCmd = (issueId: string, filePath: string) =>
invoke<ImageAttachment>("upload_image_attachment", { issueId, filePath });
export const uploadPasteImageCmd = (issueId: string, base64Image: string, mimeType: string) =>
invoke<ImageAttachment>("upload_paste_image", { issueId, base64Image, mimeType });
export const listImageAttachmentsCmd = (issueId: string) =>
invoke<ImageAttachment[]>("list_image_attachments", { issueId });
export const deleteImageAttachmentCmd = (attachmentId: string) =>
invoke<void>("delete_image_attachment", { attachmentId });
export const detectPiiCmd = (logFileId: string) => export const detectPiiCmd = (logFileId: string) =>
invoke<PiiDetectionResult>("detect_pii", { logFileId }); invoke<PiiDetectionResult>("detect_pii", { logFileId });

View File

@ -1,16 +1,22 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useRef, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Upload, File, Trash2, ShieldCheck } from "lucide-react"; import { Upload, File, Trash2, ShieldCheck, AlertTriangle, Image as ImageIcon } from "lucide-react";
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@/components/ui"; import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@/components/ui";
import { PiiDiffViewer } from "@/components/PiiDiffViewer"; import { PiiDiffViewer } from "@/components/PiiDiffViewer";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { import {
uploadLogFileCmd, uploadLogFileCmd,
detectPiiCmd, detectPiiCmd,
uploadImageAttachmentCmd,
uploadPasteImageCmd,
listImageAttachmentsCmd,
deleteImageAttachmentCmd,
type LogFile, type LogFile,
type PiiSpan, type PiiSpan,
type PiiDetectionResult, type PiiDetectionResult,
type ImageAttachment,
} from "@/lib/tauriCommands"; } from "@/lib/tauriCommands";
import ImageGallery from "@/components/ImageGallery";
export default function LogUpload() { export default function LogUpload() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -18,11 +24,14 @@ export default function LogUpload() {
const { piiSpans, approvedRedactions, setPiiSpans, setApprovedRedactions } = useSessionStore(); const { piiSpans, approvedRedactions, setPiiSpans, setApprovedRedactions } = useSessionStore();
const [files, setFiles] = useState<{ file: File; uploaded?: LogFile }[]>([]); const [files, setFiles] = useState<{ file: File; uploaded?: LogFile }[]>([]);
const [images, setImages] = useState<ImageAttachment[]>([]);
const [piiResult, setPiiResult] = useState<PiiDetectionResult | null>(null); const [piiResult, setPiiResult] = useState<PiiDetectionResult | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -96,9 +105,136 @@ export default function LogUpload() {
} }
}; };
const handleImageDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/"));
if (imageFiles.length > 0) {
handleImagesUpload(imageFiles);
}
},
[id]
);
const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selected = Array.from(e.target.files).filter((f) => f.type.startsWith("image/"));
if (selected.length > 0) {
handleImagesUpload(selected);
}
}
};
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : [];
for (const item of imageItems) {
const file = item.getAsFile();
if (file) {
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
try {
const result = await uploadPasteImageCmd(id || "", base64Data, file.type);
setImages((prev) => [...prev, result]);
} catch (err) {
setError(String(err));
}
};
reader.readAsDataURL(file);
}
}
},
[id]
);
const handleImagesUpload = async (imageFiles: File[]) => {
if (!id || imageFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
const uploaded = await Promise.all(
imageFiles.map(async (file) => {
const result = await uploadImageAttachmentCmd(id, file.name);
return result;
})
);
setImages((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(String(err));
} finally {
setIsUploading(false);
}
};
const handleDeleteImage = async (image: ImageAttachment) => {
try {
await deleteImageAttachmentCmd(image.id);
setImages((prev) => prev.filter((img) => img.id !== image.id));
} catch (err) {
setError(String(err));
}
};
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
};
const allUploaded = files.length > 0 && files.every((f) => f.uploaded); const allUploaded = files.length > 0 && files.every((f) => f.uploaded);
const piiReviewed = piiResult != null; const piiReviewed = piiResult != null;
useEffect(() => {
const handleGlobalPaste = (e: ClipboardEvent) => {
if (document.activeElement?.tagName === "INPUT" ||
document.activeElement?.tagName === "TEXTAREA" ||
(document.activeElement as HTMLElement)?.isContentEditable || false) {
return;
}
const items = e.clipboardData?.items;
const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : [];
for (const item of imageItems) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
try {
const result = await uploadPasteImageCmd(id || "", base64Data, file.type);
setImages((prev) => [...prev, result]);
} catch (err) {
setError(String(err));
}
};
reader.readAsDataURL(file);
break;
}
}
};
window.addEventListener("paste", handleGlobalPaste);
return () => window.removeEventListener("paste", handleGlobalPaste);
}, [id]);
useEffect(() => {
if (id) {
listImageAttachmentsCmd(id).then(setImages).catch(setError);
}
}, [id]);
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div>
@ -165,6 +301,87 @@ export default function LogUpload() {
</Card> </Card>
)} )}
{/* Image Upload */}
{id && (
<>
<div>
<h2 className="text-2xl font-semibold flex items-center gap-2">
<ImageIcon className="w-6 h-6" />
Image Attachments
</h2>
<p className="text-muted-foreground mt-1">
Upload or paste screenshots and images.
</p>
</div>
{/* Image drop zone */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleImageDrop}
className="border-2 border-dashed border-primary/30 rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer bg-primary/5"
onClick={() => document.getElementById("image-input")?.click()}
>
<Upload className="w-8 h-8 mx-auto text-primary mb-2" />
<p className="text-sm text-muted-foreground">
Drag and drop images here, or click to browse
</p>
<p className="text-xs text-muted-foreground mt-2">
Supported: PNG, JPEG, GIF, WebP, SVG
</p>
<input
id="image-input"
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFileSelect}
/>
</div>
{/* Paste button */}
<div className="flex items-center gap-2">
<Button
onClick={async (e) => {
e.preventDefault();
document.execCommand("paste");
}}
variant="secondary"
>
Paste from Clipboard
</Button>
<span className="text-xs text-muted-foreground">
Use Ctrl+V / Cmd+V or the button above to paste images
</span>
</div>
{/* PII warning for images */}
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
<AlertTriangle className="w-5 h-5 text-amber-600 inline mr-2" />
<span className="text-sm text-amber-800">
PII cannot be automatically redacted from images. Use at your own risk.
</span>
</div>
{/* Image Gallery */}
{images.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
Attached Images ({images.length})
</CardTitle>
</CardHeader>
<CardContent>
<ImageGallery
images={images}
onDelete={handleDeleteImage}
showWarning={false}
/>
</CardContent>
</Card>
)}
</>
)}
{/* PII Detection */} {/* PII Detection */}
{allUploaded && ( {allUploaded && (
<Card> <Card>

View File

@ -21,6 +21,7 @@ const mockIssueDetail = {
tags: "[]", tags: "[]",
}, },
log_files: [], log_files: [],
image_attachments: [],
resolution_steps: [ resolution_steps: [
{ {
id: "step-1", id: "step-1",