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:
parent
7112fbc0c1
commit
f7f7f51321
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|
||||||
|
|||||||
@ -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
94
src-tauri/Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
282
src-tauri/src/commands/image.rs
Normal file
282
src-tauri/src/commands/image.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
87ea7716dc80a07a
|
0ec3867c61057b8d
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
4654627634d98be6
|
f18cdeecdcc959ec
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
e63c935cfe3a4323
|
dbcbb845f19978d4
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
6449d31d465814cb
|
771443a67e1ee643
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
57f4bc49b33c001c
|
5803222d0e0fddb9
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
69504a124cbc9852
|
9f974a009171d4ef
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
a5af7b5e4d4405c6
|
60eed350720277b1
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
08423045a7765f4d
|
92f69e3fffb99380
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
037bfe3470f35a56
|
e9d3de655c7fb8fb
|
||||||
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
99e554c7b6b7566f
|
b66f3fbbb59328a7
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
1b89f0fddc51bf06
|
1ccdb81f9803dbf3
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
24489362717cfd07
|
783fa28e59dbf287
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
ac668622e72824cc
|
8bf395a905eeb180
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
12fb31ec9153a35c
|
d2f18ca9005cef98
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
55aa6e20999fcd8c
|
88b1680aca4836b8
|
||||||
@ -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}
|
||||||
@ -1 +1 @@
|
|||||||
14e35fb7bb7956a1
|
f39cae21ec772c7e
|
||||||
@ -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}
|
||||||
@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
165
src/components/ImageGallery.tsx
Normal file
165
src/components/ImageGallery.tsx
Normal 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;
|
||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const mockIssueDetail = {
|
|||||||
tags: "[]",
|
tags: "[]",
|
||||||
},
|
},
|
||||||
log_files: [],
|
log_files: [],
|
||||||
|
image_attachments: [],
|
||||||
resolution_steps: [
|
resolution_steps: [
|
||||||
{
|
{
|
||||||
id: "step-1",
|
id: "step-1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user