feat: attachment DB storage and cross-incident recall #58

Merged
sarman merged 2 commits from feature/attachment-db-storage-recall into master 2026-05-31 23:12:39 +00:00
14 changed files with 1269 additions and 34 deletions

View File

@ -0,0 +1,118 @@
# Ticket: Attachment DB Storage & Cross-Incident Recall
**Branch:** `feature/attachment-db-storage-recall`
**Base:** `master`
---
## Description
Log file and image attachment records previously stored only metadata and filesystem paths, making content volatile — if the source file moved or was deleted, the attachment record became orphaned. There was also no mechanism to search or recall attachments across incidents.
This feature:
1. Stores **gzip-compressed** log text and **raw image bytes** directly in the database, making attachments fully self-contained and portable.
2. Surfaces a new **Attachments tab** on the History page for cross-incident search and recall.
3. Exposes content-retrieval commands so the AI chat context can reference log content from DB on demand, with no disk dependency.
---
## Acceptance Criteria
- [x] Uploading a log file stores gzip-compressed text in `log_files.content_compressed` (BLOB)
- [x] Uploading an image stores raw bytes in `image_attachments.image_data` (BLOB)
- [x] `get_log_file_content` returns decompressed text from DB; falls back to disk for pre-migration records
- [x] `get_image_attachment_data` returns base64 data URL from DB; falls back to disk for pre-migration records
- [x] `list_all_log_files` returns cross-incident log summaries with joined issue title, supports search and issueId filter
- [x] `list_all_image_attachments` returns cross-incident image summaries with joined issue title, supports search and issueId filter
- [x] History page shows two tabs: **Issues** (existing, unchanged) and **Attachments** (new)
- [x] Attachments tab: Log Files section with filename, incident link, date, size, type badge, View button
- [x] Attachments tab: Images section with 48px thumbnail, filename, incident link, date, View button
- [x] "View" on log file → modal showing decompressed plain text
- [x] "View" on image → modal showing full-size image
- [x] Existing records with NULL content fall back to disk read — no breakage for pre-migration data
- [x] All new DB changes tracked via migrations 020022 with idempotency guarantees
- [x] Wiki documentation updated: IPC-Commands.md and Database.md
---
## Work Implemented
### Database (`src-tauri/src/db/`)
| File | Change |
|---|---|
| `migrations.rs` | Migrations 020 (`content_compressed BLOB`), 021 (`image_data BLOB`), 022 (views `v_log_files_with_issue` + `v_image_attachments_with_issue`). Extended duplicate-column graceful handling for new ALTER TABLE migrations. |
| `models.rs` | Added `LogFileSummary` and `ImageAttachmentSummary` structs for lightweight cross-incident list views (no BLOB fields — content stays out of IPC). |
### Rust Backend (`src-tauri/src/commands/`)
| File | Change |
|---|---|
| `analysis.rs` | Private `compress_text` / `decompress_text` helpers (flate2/miniz_oxide — pure Rust, no system binary). Updated `upload_log_file` and `upload_log_file_by_content` INSERTs to store `content_compressed`. New commands: `get_log_file_content`, `list_all_log_files`. |
| `image.rs` | Updated `upload_image_attachment`, `upload_image_attachment_by_content`, `upload_paste_image` INSERTs to store `image_data`. New commands: `get_image_attachment_data`, `list_all_image_attachments`. |
| `lib.rs` | Registered all 4 new commands. |
### Dependencies (`src-tauri/Cargo.toml`)
- Added `flate2 = { version = "1", features = ["rust_backend"] }` — pure-Rust gzip, portable cross-platform.
### Frontend (`src/`)
| File | Change |
|---|---|
| `lib/tauriCommands.ts` | Added `LogFileSummary`, `ImageAttachmentSummary` interfaces and 4 typed command wrappers. |
| `stores/attachmentStore.ts` | New Zustand store: `loadAttachments`, `searchAttachments`, `setSearchQuery`. |
| `pages/History/index.tsx` | Added tab bar; extracted `IssuesTab` (existing content, unchanged); added `AttachmentsTab` with log/image tables, search, View modals, and lazy `ImageThumbnail` component. |
### Documentation (`docs/wiki/`)
| File | Change |
|---|---|
| `IPC-Commands.md` | Documented `get_log_file_content`, `list_all_log_files`, `get_image_attachment_data`, `list_all_image_attachments` with TypeScript signatures and interface shapes. Updated upload command notes. |
| `Database.md` | Updated migration count (18 → 22). Documented migrations 020, 021, 022 with SQL, rationale, and usage notes. |
---
## Testing Needed
### Automated (already passing)
| Suite | Count | Status |
|---|---|---|
| Rust unit tests (`cargo test`) | 226 | ✅ All pass |
| Frontend unit tests (`npm run test:run`) | 103 | ✅ All pass |
| TypeScript type check (`tsc --noEmit`) | — | ✅ Clean |
| Rust clippy (`clippy -- -D warnings`) | — | ✅ Zero warnings |
| Rust format (`fmt --check`) | — | ✅ Clean |
New tests added:
- `test_compress_decompress_roundtrip`, `test_compress_large_text_is_smaller`, `test_decompress_invalid_bytes_returns_error` (Rust, `analysis.rs`)
- `test_get_image_attachment_data_base64_format` (Rust, `image.rs`)
- `test_020_log_content_compressed_column`, `test_021_image_data_column`, `test_022_attachment_views_exist`, `test_022_views_join_issue_title`, `test_020_021_idempotent` (Rust, `migrations.rs`)
- 9 attachment store tests (`tests/unit/attachmentStore.test.ts`)
### Manual Smoke Testing Required
1. **Log upload → DB content storage**
- Create issue → upload `.log` file → inspect SQLite: `SELECT id, LENGTH(content_compressed) FROM log_files` — verify non-NULL non-zero value
2. **Content retrieval from DB**
- History → Attachments tab → Log Files → click "View" → confirm readable decompressed text appears in modal
3. **Fallback for pre-migration records**
- Manually `UPDATE log_files SET content_compressed = NULL WHERE id = '<id>'` → View should still load from disk path
4. **Image upload → DB byte storage**
- Upload image → `SELECT id, LENGTH(image_data) FROM image_attachments` — verify non-NULL
5. **Image display**
- History → Attachments tab → Images → thumbnails should render, View → full-size image modal
6. **Cross-incident search**
- Create 2+ issues with different log files → Attachments tab → search by partial filename → correct files appear
7. **Issue link navigation**
- Click incident title in Attachments tab → navigates to correct triage page
8. **Issue tab unchanged**
- Verify existing Issues tab retains all functionality (search, filter, sort, open, export buttons)

View File

@ -2,7 +2,7 @@
## Overview
TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 18 versioned migrations are tracked in the `_migrations` table.
TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 22 versioned migrations are tracked in the `_migrations` table.
**DB file location:** `{app_data_dir}/tftsr.db`
@ -344,6 +344,51 @@ CREATE TABLE mcp_resources (
- Cascade deletes ensure removing a server cleans up all associated tools and resources
- Tools and resources are replaced atomically on each discovery run (delete-all + re-insert)
### 020 — log_files content storage (Attachment Recall v0.4+)
```sql
ALTER TABLE log_files ADD COLUMN content_compressed BLOB;
```
Stores gzip-compressed extracted text for every log file uploaded after migration 020. Existing rows remain `NULL` and fall back to the `file_path` column.
**Compression:** pure-Rust gzip via `flate2` (`rust_backend` / `miniz_oxide`) — no external binary dependency, works identically on Linux, Windows, macOS.
**Usage:** The `get_log_file_content` command reads and decompresses this column. The column is never serialised to the frontend directly — content is requested on demand via IPC.
### 021 — image_attachments byte storage (Attachment Recall v0.4+)
```sql
ALTER TABLE image_attachments ADD COLUMN image_data BLOB;
```
Stores raw image bytes for every image uploaded after migration 021. Existing rows fall back to `file_path`. Images are already compressed (PNG/JPEG) so no additional compression is applied.
**Usage:** `get_image_attachment_data` reads this column and base64-encodes it into a data URL for frontend display. The `ImageThumbnail` component in the History → Attachments tab calls this on mount for each visible image.
### 022 — attachment cross-incident views (Attachment Recall v0.4+)
Two read-only views joining attachments with their parent issue titles:
```sql
CREATE VIEW IF NOT EXISTS v_log_files_with_issue AS
SELECT lf.id, lf.issue_id, lf.file_name, lf.file_path, lf.file_size,
lf.mime_type, lf.content_hash, lf.uploaded_at, lf.redacted,
i.title AS issue_title
FROM log_files lf
JOIN issues i ON i.id = lf.issue_id;
CREATE VIEW IF NOT EXISTS v_image_attachments_with_issue AS
SELECT ia.id, ia.issue_id, ia.file_name, ia.file_path, ia.file_size,
ia.mime_type, ia.upload_hash, ia.uploaded_at,
ia.pii_warning_acknowledged, ia.is_paste,
i.title AS issue_title
FROM image_attachments ia
JOIN issues i ON i.id = ia.issue_id;
```
Used by `list_all_log_files` and `list_all_image_attachments` to power the cross-incident Attachments tab in the History page. Explicitly selects named columns (not `SELECT *`) to avoid including the BLOB data in list queries.
---
## Key Design Notes

View File

@ -113,6 +113,34 @@ applyRedactionsCmd(logFileId: string, approvedSpanIds: string[]) → RedactedLog
```
Rewrites file content with approved redactions. Records SHA-256 in audit log. Returns redacted content path.
### `get_log_file_content`
```typescript
getLogFileContentCmd(logFileId: string) → string
```
Returns the plain-text content of a log file. Primary path: reads gzip-compressed BLOB from the `content_compressed` column and decompresses in-process (no external binary required). Fallback: reads from `file_path` on disk for records uploaded before migration 020.
Used by the triage chat context loader and the "View" modal in the Attachments tab.
### `list_all_log_files`
```typescript
listAllLogFilesCmd(search?: string, issueId?: string) → LogFileSummary[]
```
Cross-incident log file listing via `v_log_files_with_issue`. Optional `search` performs `file_name LIKE '%q%'`; optional `issueId` filters to a single incident. Ordered by `uploaded_at DESC`. Never includes the compressed content blob — content is fetched separately via `get_log_file_content`.
```typescript
interface LogFileSummary {
id: string;
issue_id: string;
issue_title: string; // joined from issues table
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
content_hash: string;
uploaded_at: string;
redacted: boolean;
}
```
---
## Image Attachment Commands
@ -141,6 +169,37 @@ uploadPasteImageCmd(issueId: string, base64Data: string, fileName: string, piiWa
```
Uploads an image from clipboard paste (base64). Returns `ImageAttachment` record.
**Note (v0.4+):** All three upload commands (`upload_image_attachment`, `upload_image_attachment_by_content`, `upload_paste_image`) now also store the raw image bytes in the `image_data` column of `image_attachments`, enabling retrieval without requiring the source file on disk.
### `get_image_attachment_data`
```typescript
getImageAttachmentDataCmd(attachmentId: string) → string
```
Returns image content as a base64 data URL (`data:<mime>;base64,...`). Primary path: reads raw bytes from the `image_data` BLOB column. Fallback: reads from `file_path` on disk for records uploaded before migration 021.
Suitable for use directly as an `<img src>` value or in the "View" modal.
### `list_all_image_attachments`
```typescript
listAllImageAttachmentsCmd(search?: string, issueId?: string) → ImageAttachmentSummary[]
```
Cross-incident image listing via `v_image_attachments_with_issue`. Optional `search` performs `file_name LIKE '%q%'`; optional `issueId` filters to a single incident. Ordered by `uploaded_at DESC`. Never includes the raw image bytes blob.
```typescript
interface ImageAttachmentSummary {
id: string;
issue_id: string;
issue_title: string; // joined from issues table
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;
}
```
---
## AI Commands

1
src-tauri/Cargo.lock generated
View File

@ -6367,6 +6367,7 @@ dependencies = [
"chrono",
"dirs 5.0.1",
"docx-rs",
"flate2",
"futures",
"hex",
"infer 0.15.0",

View File

@ -53,6 +53,7 @@ rmcp = { version = "1.7.0", features = [
"transport-child-process",
"transport-streamable-http-client-reqwest",
] }
flate2 = { version = "1", features = ["rust_backend"] }
[dev-dependencies]
tokio-test = "0.4"

View File

@ -1,9 +1,14 @@
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use sha2::{Digest, Sha256};
use std::io::Read as IoRead;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::State;
use tracing::warn;
use crate::db::models::{AuditEntry, LogFile, PiiSpanRecord};
use crate::db::models::{AuditEntry, LogFile, LogFileSummary, PiiSpanRecord};
use crate::pii::{self, PiiDetectionResult, PiiDetector, RedactedLogFile};
use crate::state::AppState;
@ -55,6 +60,30 @@ const SAFE_TEXT_EXTENSIONS: &[&str] = &[
const SAFE_BINARY_EXTENSIONS: &[&str] = &["pdf", "docx", "doc", "xlsx", "xls"];
fn compress_text(text: &str) -> Result<Vec<u8>, String> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(text.as_bytes())
.map_err(|e| format!("Compression write error: {e}"))?;
encoder.finish().map_err(|e| format!("Compression finish error: {e}"))
}
/// 100 MB cap — prevents decompression-bomb attacks on crafted DB entries.
const MAX_DECOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;
fn decompress_text(bytes: &[u8]) -> Result<String, String> {
let decoder = GzDecoder::new(bytes);
let mut limited = decoder.take(MAX_DECOMPRESSED_BYTES + 1);
let mut s = String::new();
limited
.read_to_string(&mut s)
.map_err(|e| format!("Failed to decompress: {e}"))?;
if s.len() as u64 > MAX_DECOMPRESSED_BYTES {
return Err("Decompressed content exceeds 100 MB limit".to_string());
}
Ok(s)
}
pub fn is_safe_file(path: &Path) -> bool {
let ext = path
.extension()
@ -229,10 +258,13 @@ pub async fn upload_log_file(
..log_file
};
let compressed = compress_text(&extracted_text)
.map_err(|e| format!("Failed to compress log content: {e}"))?;
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted, content_compressed) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
log_file.id,
log_file.issue_id,
@ -243,6 +275,7 @@ pub async fn upload_log_file(
log_file.content_hash,
log_file.uploaded_at,
log_file.redacted as i32,
compressed,
],
)
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
@ -319,10 +352,13 @@ pub async fn upload_log_file_by_content(
..log_file
};
let compressed = compress_text(&content)
.map_err(|e| format!("Failed to compress log content: {e}"))?;
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted, content_compressed) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
log_file.id,
log_file.issue_id,
@ -333,6 +369,7 @@ pub async fn upload_log_file_by_content(
log_file.content_hash,
log_file.uploaded_at,
log_file.redacted as i32,
compressed,
],
)
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
@ -497,6 +534,70 @@ pub async fn apply_redactions(
})
}
#[tauri::command]
pub async fn get_log_file_content(
log_file_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let row: (Option<Vec<u8>>, String) = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.prepare("SELECT content_compressed, file_path FROM log_files WHERE id = ?1")
.and_then(|mut stmt| {
stmt.query_row([&log_file_id], |row| Ok((row.get(0)?, row.get(1)?)))
})
.map_err(|_| "Log file not found".to_string())?
};
let (compressed, file_path) = row;
if let Some(bytes) = compressed {
return decompress_text(&bytes);
}
std::fs::read_to_string(&file_path).map_err(|e| format!("Log file not found on disk: {e}"))
}
#[tauri::command]
pub async fn list_all_log_files(
search: Option<String>,
issue_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<LogFileSummary>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut query = "SELECT id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted, issue_title \
FROM v_log_files_with_issue WHERE 1=1"
.to_string();
let mut params: Vec<String> = Vec::new();
if let Some(ref q) = search {
query.push_str(" AND file_name LIKE ?");
params.push(format!("%{q}%"));
}
if let Some(ref id) = issue_id {
query.push_str(" AND issue_id = ?");
params.push(id.clone());
}
query.push_str(" ORDER BY uploaded_at DESC");
let mut stmt = db.prepare(&query).map_err(|e| e.to_string())?;
let results = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(LogFileSummary {
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)?,
content_hash: row.get(6)?,
uploaded_at: row.get(7)?,
redacted: row.get::<_, i32>(8)? != 0,
issue_title: row.get(9)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
@ -581,4 +682,56 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("not yet supported"));
}
#[test]
fn test_compress_decompress_roundtrip() {
let original = "Hello, World! This is a log line with some content.";
let compressed = compress_text(original).unwrap();
assert!(!compressed.is_empty());
assert!(compressed.len() < original.len() * 3);
let decompressed = decompress_text(&compressed).unwrap();
assert_eq!(decompressed, original);
}
#[test]
fn test_compress_returns_error_not_empty_on_failure() {
// compress_text returns Result — callers must propagate, not silently discard.
// For in-memory gzip this essentially never fails, but the API now allows
// callers to surface the error rather than storing empty bytes.
let result = compress_text("normal log line");
assert!(result.is_ok(), "compress_text should succeed for normal input");
assert!(!result.unwrap().is_empty());
}
#[test]
fn test_compress_large_text_is_smaller() {
let original = "INFO server started\n".repeat(1000);
let compressed = compress_text(&original).unwrap();
assert!(
compressed.len() < original.len(),
"gzip should compress repetitive text"
);
}
#[test]
fn test_decompress_invalid_bytes_returns_error() {
let result = decompress_text(b"not gzip data");
assert!(result.is_err());
}
#[test]
fn test_decompress_size_limit_enforced() {
assert_eq!(
MAX_DECOMPRESSED_BYTES,
100 * 1024 * 1024,
"Decompression bomb guard must be 100 MB"
);
// A valid small payload must still decompress fine after the guard is in place.
let text = "hello world decompression guard test\n".repeat(100);
let compressed = compress_text(&text).unwrap();
let result = decompress_text(&compressed);
assert!(result.is_ok());
assert_eq!(result.unwrap(), text);
}
}

View File

@ -4,7 +4,7 @@ use std::path::Path;
use tauri::State;
use crate::audit::log::write_audit_event;
use crate::db::models::{AuditEntry, ImageAttachment};
use crate::db::models::{AuditEntry, ImageAttachment, ImageAttachmentSummary};
use crate::state::AppState;
const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024;
@ -82,8 +82,8 @@ pub async fn upload_image_attachment(
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)",
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste, image_data) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
attachment.id,
attachment.issue_id,
@ -95,6 +95,7 @@ pub async fn upload_image_attachment(
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
content,
],
)
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
@ -139,6 +140,13 @@ pub async fn upload_image_attachment_by_content(
.decode(data_part)
.map_err(|_| "Failed to decode base64 image data")?;
if decoded.len() as u64 > MAX_IMAGE_FILE_BYTES {
return Err(format!(
"Image content exceeds maximum supported size ({} MB)",
MAX_IMAGE_FILE_BYTES / 1024 / 1024
));
}
let content_hash = format!("{:x}", sha2::Sha256::digest(&decoded));
let file_size = decoded.len() as i64;
@ -168,8 +176,8 @@ pub async fn upload_image_attachment_by_content(
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)",
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste, image_data) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
attachment.id,
attachment.issue_id,
@ -181,6 +189,7 @@ pub async fn upload_image_attachment_by_content(
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
decoded,
],
)
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
@ -229,6 +238,13 @@ pub async fn upload_paste_image(
.decode(data_part)
.map_err(|_| "Failed to decode base64 image data")?;
if decoded.len() as u64 > MAX_IMAGE_FILE_BYTES {
return Err(format!(
"Pasted image exceeds maximum supported size ({} MB)",
MAX_IMAGE_FILE_BYTES / 1024 / 1024
));
}
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());
@ -254,8 +270,8 @@ pub async fn upload_paste_image(
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)",
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste, image_data) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
attachment.id,
attachment.issue_id,
@ -267,6 +283,7 @@ pub async fn upload_paste_image(
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
decoded,
],
)
.map_err(|_| "Failed to store pasted image metadata".to_string())?;
@ -591,6 +608,77 @@ pub async fn upload_file_to_datastore_any(
Ok(file_id)
}
#[tauri::command]
pub async fn get_image_attachment_data(
attachment_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let row: (Option<Vec<u8>>, String, String) = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.prepare("SELECT image_data, file_path, mime_type FROM image_attachments WHERE id = ?1")
.and_then(|mut stmt| {
stmt.query_row([&attachment_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
})
.map_err(|_| "Image attachment not found".to_string())?
};
let (image_data, file_path, mime_type) = row;
let bytes = if let Some(data) = image_data {
data
} else {
std::fs::read(&file_path).map_err(|e| format!("Image file not found on disk: {e}"))?
};
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{mime_type};base64,{b64}"))
}
#[tauri::command]
pub async fn list_all_image_attachments(
search: Option<String>,
issue_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<ImageAttachmentSummary>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut query = "SELECT id, issue_id, file_name, file_path, file_size, mime_type, \
upload_hash, uploaded_at, pii_warning_acknowledged, is_paste, issue_title \
FROM v_image_attachments_with_issue WHERE 1=1"
.to_string();
let mut params: Vec<String> = Vec::new();
if let Some(ref q) = search {
query.push_str(" AND file_name LIKE ?");
params.push(format!("%{q}%"));
}
if let Some(ref id) = issue_id {
query.push_str(" AND issue_id = ?");
params.push(id.clone());
}
query.push_str(" ORDER BY uploaded_at DESC");
let mut stmt = db.prepare(&query).map_err(|e| e.to_string())?;
let results = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(ImageAttachmentSummary {
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,
issue_title: row.get(10)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
@ -605,4 +693,13 @@ mod tests {
assert!(is_supported_image_format("image/bmp"));
assert!(!is_supported_image_format("text/plain"));
}
#[test]
fn test_get_image_attachment_data_base64_format() {
let bytes = b"\x89PNG\r\n\x1a\n";
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let result = format!("data:{};base64,{}", "image/png", b64);
assert!(result.starts_with("data:image/png;base64,"));
assert!(!result.is_empty());
}
}

View File

@ -259,6 +259,30 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
),
(
"020_add_log_content_compressed",
"ALTER TABLE log_files ADD COLUMN content_compressed BLOB",
),
(
"021_add_image_data",
"ALTER TABLE image_attachments ADD COLUMN image_data BLOB",
),
(
"022_attachment_views",
"CREATE VIEW IF NOT EXISTS v_log_files_with_issue AS
SELECT lf.id, lf.issue_id, lf.file_name, lf.file_path, lf.file_size,
lf.mime_type, lf.content_hash, lf.uploaded_at, lf.redacted,
i.title AS issue_title
FROM log_files lf
JOIN issues i ON i.id = lf.issue_id;
CREATE VIEW IF NOT EXISTS v_image_attachments_with_issue AS
SELECT ia.id, ia.issue_id, ia.file_name, ia.file_path, ia.file_size,
ia.mime_type, ia.upload_hash, ia.uploaded_at,
ia.pii_warning_acknowledged, ia.is_paste,
i.title AS issue_title
FROM image_attachments ia
JOIN issues i ON i.id = ia.issue_id;",
),
];
for (name, sql) in migrations {
@ -276,6 +300,8 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
}
} else if name.ends_with("_add_use_datastore_upload")
|| name.ends_with("_add_created_at")
|| name.ends_with("_add_log_content_compressed")
|| name.ends_with("_add_image_data")
{
// Use execute for ALTER TABLE (SQLite only allows one statement per command)
// Skip error if column already exists (SQLITE_ERROR with "duplicate column name")
@ -1101,4 +1127,110 @@ mod tests {
.unwrap();
assert_eq!(applied, 1, "019 should only be recorded once");
}
// ─── Migration 020-022: attachment content storage ──────────────────────────
#[test]
fn test_020_log_content_compressed_column() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(log_files)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
columns.contains(&"content_compressed".to_string()),
"log_files should have content_compressed column"
);
}
#[test]
fn test_021_image_data_column() {
let conn = setup_test_db();
let mut stmt = conn
.prepare("PRAGMA table_info(image_attachments)")
.unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
columns.contains(&"image_data".to_string()),
"image_attachments should have image_data column"
);
}
#[test]
fn test_022_attachment_views_exist() {
let conn = setup_test_db();
for view in &["v_log_files_with_issue", "v_image_attachments_with_issue"] {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name=?1",
[view],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "view {view} should exist");
}
}
#[test]
fn test_022_views_join_issue_title() {
let conn = setup_test_db();
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-view-1", "Disk Full Alert", now.clone(), now.clone()],
)
.unwrap();
conn.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, uploaded_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"lf-1",
"issue-view-1",
"syslog.log",
"/var/log/syslog",
now.clone()
],
)
.unwrap();
let issue_title: String = conn
.query_row(
"SELECT issue_title FROM v_log_files_with_issue WHERE id = 'lf-1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(issue_title, "Disk Full Alert");
}
#[test]
fn test_020_021_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
for migration in &[
"020_add_log_content_compressed",
"021_add_image_data",
"022_attachment_views",
] {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
[migration],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "{migration} should be recorded exactly once");
}
}
}

View File

@ -433,6 +433,41 @@ pub struct ImageAttachment {
pub is_paste: bool,
}
// ─── Attachment Summaries (cross-incident list views) ───────────────────────
/// Lightweight log-file row joined with the parent issue title.
/// Returned by `list_all_log_files` — never contains the compressed content blob.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogFileSummary {
pub id: String,
pub issue_id: String,
pub issue_title: String,
pub file_name: String,
pub file_path: String,
pub file_size: i64,
pub mime_type: String,
pub content_hash: String,
pub uploaded_at: String,
pub redacted: bool,
}
/// Lightweight image-attachment row joined with the parent issue title.
/// Returned by `list_all_image_attachments` — never contains the raw image bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageAttachmentSummary {
pub id: String,
pub issue_id: String,
pub issue_title: 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(

View File

@ -86,11 +86,15 @@ pub fn run() {
commands::analysis::upload_log_file_by_content,
commands::analysis::detect_pii,
commands::analysis::apply_redactions,
commands::analysis::get_log_file_content,
commands::analysis::list_all_log_files,
commands::image::upload_image_attachment,
commands::image::upload_image_attachment_by_content,
commands::image::list_image_attachments,
commands::image::delete_image_attachment,
commands::image::upload_paste_image,
commands::image::get_image_attachment_data,
commands::image::list_all_image_attachments,
commands::image::upload_file_to_datastore,
commands::image::upload_file_to_datastore_any,
// AI

View File

@ -615,3 +615,52 @@ export const clearSudoPasswordCmd = () =>
export const getAppVersionCmd = () =>
invoke<string>("get_app_version");
// ─── Attachment cross-incident types ─────────────────────────────────────────
export interface LogFileSummary {
id: string;
issue_id: string;
issue_title: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
content_hash: string;
uploaded_at: string;
redacted: boolean;
}
export interface ImageAttachmentSummary {
id: string;
issue_id: string;
issue_title: 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;
}
// ─── Attachment cross-incident commands ───────────────────────────────────────
export const getLogFileContentCmd = (logFileId: string) =>
invoke<string>("get_log_file_content", { logFileId });
export const listAllLogFilesCmd = (search?: string, issueId?: string) =>
invoke<LogFileSummary[]>("list_all_log_files", {
search: search ?? null,
issueId: issueId ?? null,
});
export const getImageAttachmentDataCmd = (attachmentId: string) =>
invoke<string>("get_image_attachment_data", { attachmentId });
export const listAllImageAttachmentsCmd = (search?: string, issueId?: string) =>
invoke<ImageAttachmentSummary[]>("list_all_image_attachments", {
search: search ?? null,
issueId: issueId ?? null,
});

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search, Download, ExternalLink } from "lucide-react";
import { Search, Download, ExternalLink, FileText, Image, Eye } from "lucide-react";
import {
Card,
CardContent,
@ -14,10 +14,46 @@ import {
SelectItem,
} from "@/components/ui";
import { useHistoryStore } from "@/stores/historyStore";
import { useAttachmentStore } from "@/stores/attachmentStore";
import { getLogFileContentCmd, getImageAttachmentDataCmd } from "@/lib/tauriCommands";
import { DOMAINS } from "@/lib/domainPrompts";
type TabId = "issues" | "attachments";
export default function History() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabId>("issues");
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">History</h1>
{/* Tab bar */}
<div className="flex border-b">
{(["issues", "attachments"] as TabId[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
"px-5 py-2 text-sm font-medium capitalize transition-colors",
activeTab === tab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground",
].join(" ")}
>
{tab}
</button>
))}
</div>
{activeTab === "issues" ? <IssuesTab navigate={navigate} /> : <AttachmentsTab navigate={navigate} />}
</div>
);
}
// ─── Issues Tab (unchanged content) ────────────────────────────────────────
function IssuesTab({ navigate }: { navigate: ReturnType<typeof useNavigate> }) {
const { issues, isLoading, searchQuery, loadIssues, searchIssues, setSearchQuery } =
useHistoryStore();
@ -74,9 +110,7 @@ export default function History() {
sortField === field ? (sortAsc ? " ↑" : " ↓") : "";
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">History</h1>
<>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[200px]">
@ -213,32 +247,324 @@ export default function History() {
)}
</CardContent>
</Card>
</div>
</>
);
}
// ─── Attachments Tab ────────────────────────────────────────────────────────
function AttachmentsTab({ navigate }: { navigate: ReturnType<typeof useNavigate> }) {
const { logFiles, images, isLoading, error, searchQuery, loadAttachments, searchAttachments, setSearchQuery } =
useAttachmentStore();
const [viewModal, setViewModal] = useState<{ type: "log" | "image"; id: string; title: string } | null>(null);
const [modalContent, setModalContent] = useState<string | null>(null);
const [modalError, setModalError] = useState<string | null>(null);
const [modalLoading, setModalLoading] = useState(false);
useEffect(() => {
loadAttachments();
}, [loadAttachments]);
const handleSearch = () => {
searchAttachments(searchQuery);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
};
const openLogModal = async (id: string, fileName: string) => {
setViewModal({ type: "log", id, title: fileName });
setModalContent(null);
setModalLoading(true);
try {
const content = await getLogFileContentCmd(id);
setModalContent(content);
} catch (e) {
setModalContent(`Error loading content: ${String(e)}`);
} finally {
setModalLoading(false);
}
};
const openImageModal = async (id: string, fileName: string) => {
setViewModal({ type: "image", id, title: fileName });
setModalContent(null);
setModalError(null);
setModalLoading(true);
try {
const dataUrl = await getImageAttachmentDataCmd(id);
setModalContent(dataUrl);
} catch (e) {
setModalError(String(e));
} finally {
setModalLoading(false);
}
};
const closeModal = () => {
setViewModal(null);
setModalContent(null);
setModalError(null);
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<>
{/* Search bar */}
<div className="flex gap-3">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search attachments by filename..."
className="pl-9"
/>
</div>
</div>
<Button onClick={handleSearch}>
<Search className="w-4 h-4 mr-2" />
Search
</Button>
</div>
{isLoading && (
<div className="p-8 text-center text-muted-foreground">Loading attachments...</div>
)}
{error && (
<div className="p-4 text-sm text-destructive border border-destructive/30 rounded">{error}</div>
)}
{!isLoading && (
<div className="space-y-6">
{/* Log Files section */}
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<FileText className="w-5 h-5" />
Log Files
<span className="text-sm font-normal text-muted-foreground">({logFiles.length})</span>
</h2>
<Card>
<CardContent className="p-0">
{logFiles.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No log files found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">File</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Incident</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Size</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Type</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Uploaded</th>
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{logFiles.map((lf) => (
<tr key={lf.id} className="border-b last:border-0 hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm font-medium">
{lf.redacted && (
<Badge variant="outline" className="mr-2 text-xs">redacted</Badge>
)}
{lf.file_name}
</td>
<td className="px-4 py-3 text-sm">
<button
className="text-primary hover:underline text-left"
onClick={() => navigate(`/issue/${lf.issue_id}/triage`)}
>
{lf.issue_title}
</button>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{formatBytes(lf.file_size)}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{lf.mime_type}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(lf.uploaded_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => openLogModal(lf.id, lf.file_name)}
>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
{/* Images section */}
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Image className="w-5 h-5" />
Images
<span className="text-sm font-normal text-muted-foreground">({images.length})</span>
</h2>
<Card>
<CardContent className="p-0">
{images.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No images found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Preview</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">File</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Incident</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Size</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Uploaded</th>
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{images.map((img) => (
<tr key={img.id} className="border-b last:border-0 hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<ImageThumbnail attachmentId={img.id} alt={img.file_name} />
</td>
<td className="px-4 py-3 text-sm font-medium">
{img.is_paste && (
<Badge variant="outline" className="mr-2 text-xs">paste</Badge>
)}
{img.file_name}
</td>
<td className="px-4 py-3 text-sm">
<button
className="text-primary hover:underline text-left"
onClick={() => navigate(`/issue/${img.issue_id}/triage`)}
>
{img.issue_title}
</button>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{formatBytes(img.file_size)}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(img.uploaded_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => openImageModal(img.id, img.file_name)}
>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Content modal */}
{viewModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={closeModal}
>
<div
className="bg-background border rounded-lg shadow-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b">
<span className="font-medium text-sm">{viewModal.title}</span>
<Button variant="ghost" size="sm" onClick={closeModal}>Close</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{modalLoading && (
<div className="text-center text-muted-foreground py-8">Loading...</div>
)}
{!modalLoading && viewModal.type === "log" && (
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
{modalContent ?? "No content available."}
</pre>
)}
{!modalLoading && viewModal.type === "image" && modalContent && (
<img
src={modalContent}
alt={viewModal.title}
className="max-w-full max-h-[60vh] object-contain mx-auto"
/>
)}
{!modalLoading && viewModal.type === "image" && !modalContent && (
<div className="text-center py-8 space-y-2">
<div className="text-muted-foreground">Image could not be loaded.</div>
{modalError && (
<div className="text-xs text-destructive font-mono">{modalError}</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}
// ─── Image thumbnail (lazy-loads on mount) ──────────────────────────────────
function ImageThumbnail({ attachmentId, alt }: { attachmentId: string; alt: string }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getImageAttachmentDataCmd(attachmentId)
.then((data) => { if (!cancelled) setSrc(data); })
.catch(() => {});
return () => { cancelled = true; };
}, [attachmentId]);
if (!src) {
return <div className="w-12 h-12 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs"></div>;
}
return (
<img src={src} alt={alt} className="w-12 h-12 object-cover rounded" />
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function severityVariant(severity: string): "default" | "destructive" | "secondary" | "outline" {
switch (severity) {
case "P1":
return "destructive";
case "P2":
return "default";
case "P3":
return "secondary";
default:
return "outline";
case "P1": return "destructive";
case "P2": return "default";
case "P3": return "secondary";
default: return "outline";
}
}
function statusVariant(status: string): "default" | "destructive" | "secondary" | "outline" {
switch (status) {
case "open":
return "default";
case "triaging":
return "secondary";
case "resolved":
return "outline";
default:
return "outline";
case "open": return "default";
case "triaging": return "secondary";
case "resolved": return "outline";
default: return "outline";
}
}

View File

@ -0,0 +1,55 @@
import { create } from "zustand";
import type { LogFileSummary, ImageAttachmentSummary } from "@/lib/tauriCommands";
import {
listAllLogFilesCmd,
listAllImageAttachmentsCmd,
} from "@/lib/tauriCommands";
interface AttachmentState {
logFiles: LogFileSummary[];
images: ImageAttachmentSummary[];
isLoading: boolean;
error: string | null;
searchQuery: string;
loadAttachments(filter?: { issueId?: string }): Promise<void>;
searchAttachments(query: string): Promise<void>;
setSearchQuery(q: string): void;
}
export const useAttachmentStore = create<AttachmentState>((set, get) => ({
logFiles: [],
images: [],
isLoading: false,
error: null,
searchQuery: "",
loadAttachments: async (filter = {}) => {
set({ isLoading: true, error: null });
try {
const [logFiles, images] = await Promise.all([
listAllLogFilesCmd(undefined, filter.issueId),
listAllImageAttachmentsCmd(undefined, filter.issueId),
]);
set({ logFiles, images, isLoading: false });
} catch (err) {
set({ error: String(err), isLoading: false });
}
},
searchAttachments: async (query: string) => {
const search = query || undefined;
set({ isLoading: true, searchQuery: query, error: null });
try {
const [logFiles, images] = await Promise.all([
listAllLogFilesCmd(search),
listAllImageAttachmentsCmd(search),
]);
set({ logFiles, images, isLoading: false });
} catch (err) {
set({ error: String(err), isLoading: false });
}
},
setSearchQuery: (q) => set({ searchQuery: q }),
}));

View File

@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { useAttachmentStore } from "@/stores/attachmentStore";
import type { LogFileSummary, ImageAttachmentSummary } from "@/lib/tauriCommands";
const mockInvoke = vi.mocked(invoke);
const makeLogFile = (overrides: Partial<LogFileSummary> = {}): LogFileSummary => ({
id: "lf-001",
issue_id: "issue-001",
issue_title: "Disk Full Alert",
file_name: "syslog.log",
file_path: "/var/log/syslog",
file_size: 2048,
mime_type: "text/plain",
content_hash: "abc123",
uploaded_at: new Date().toISOString(),
redacted: false,
...overrides,
});
const makeImage = (overrides: Partial<ImageAttachmentSummary> = {}): ImageAttachmentSummary => ({
id: "img-001",
issue_id: "issue-001",
issue_title: "Disk Full Alert",
file_name: "screenshot.png",
file_path: "/tmp/screenshot.png",
file_size: 51200,
mime_type: "image/png",
upload_hash: "def456",
uploaded_at: new Date().toISOString(),
pii_warning_acknowledged: true,
is_paste: false,
...overrides,
});
const resetStore = () => {
useAttachmentStore.setState({
logFiles: [],
images: [],
isLoading: false,
error: null,
searchQuery: "",
});
};
describe("Attachment Store", () => {
beforeEach(() => {
resetStore();
mockInvoke.mockReset();
});
// ─── loadAttachments ──────────────────────────────────────────────────────
it("loadAttachments populates logFiles and images", async () => {
const logFiles = [makeLogFile()];
const images = [makeImage()];
mockInvoke
.mockResolvedValueOnce(logFiles) // list_all_log_files
.mockResolvedValueOnce(images); // list_all_image_attachments
await useAttachmentStore.getState().loadAttachments();
expect(useAttachmentStore.getState().logFiles).toHaveLength(1);
expect(useAttachmentStore.getState().images).toHaveLength(1);
expect(useAttachmentStore.getState().isLoading).toBe(false);
expect(useAttachmentStore.getState().error).toBeNull();
});
it("loadAttachments sets isLoading=true while in flight", async () => {
let resolveLog!: (v: unknown) => void;
mockInvoke.mockReturnValueOnce(new Promise((r) => (resolveLog = r)));
mockInvoke.mockResolvedValueOnce([]);
const p = useAttachmentStore.getState().loadAttachments();
expect(useAttachmentStore.getState().isLoading).toBe(true);
resolveLog([]);
await p;
expect(useAttachmentStore.getState().isLoading).toBe(false);
});
it("loadAttachments sets error on failure and clears isLoading", async () => {
mockInvoke.mockRejectedValueOnce(new Error("DB error"));
await useAttachmentStore.getState().loadAttachments();
expect(useAttachmentStore.getState().error).toContain("DB error");
expect(useAttachmentStore.getState().isLoading).toBe(false);
});
it("loadAttachments passes issueId filter when provided", async () => {
mockInvoke.mockResolvedValueOnce([makeLogFile()]).mockResolvedValueOnce([]);
await useAttachmentStore.getState().loadAttachments({ issueId: "issue-001" });
// Both calls should have been made with issueId
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
expect(logCall).toBeDefined();
expect(logCall![1]).toMatchObject({ issueId: "issue-001" });
});
// ─── searchAttachments ────────────────────────────────────────────────────
it("searchAttachments passes search query and updates store", async () => {
const logFiles = [makeLogFile({ file_name: "app.log" })];
mockInvoke
.mockResolvedValueOnce(logFiles)
.mockResolvedValueOnce([]);
await useAttachmentStore.getState().searchAttachments("app");
expect(useAttachmentStore.getState().searchQuery).toBe("app");
expect(useAttachmentStore.getState().logFiles).toHaveLength(1);
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
expect(logCall![1]).toMatchObject({ search: "app" });
});
it("searchAttachments with empty string clears filter", async () => {
mockInvoke.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await useAttachmentStore.getState().searchAttachments("");
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
expect(logCall![1]).toMatchObject({ search: null });
});
// ─── setSearchQuery ───────────────────────────────────────────────────────
it("setSearchQuery updates searchQuery without triggering a fetch", () => {
useAttachmentStore.getState().setSearchQuery("kernel panic");
expect(useAttachmentStore.getState().searchQuery).toBe("kernel panic");
expect(mockInvoke).not.toHaveBeenCalled();
});
// ─── data shape ───────────────────────────────────────────────────────────
it("log file summary includes issue_title", async () => {
mockInvoke
.mockResolvedValueOnce([makeLogFile({ issue_title: "Memory Leak Incident" })])
.mockResolvedValueOnce([]);
await useAttachmentStore.getState().loadAttachments();
expect(useAttachmentStore.getState().logFiles[0].issue_title).toBe("Memory Leak Incident");
});
it("image summary includes issue_title and is_paste flag", async () => {
mockInvoke
.mockResolvedValueOnce([])
.mockResolvedValueOnce([makeImage({ issue_title: "CPU Spike", is_paste: true })]);
await useAttachmentStore.getState().loadAttachments();
const img = useAttachmentStore.getState().images[0];
expect(img.issue_title).toBe("CPU Spike");
expect(img.is_paste).toBe(true);
});
});