feat: attachment DB storage and cross-incident recall #58
118
TICKET-attachment-db-storage-recall.md
Normal file
118
TICKET-attachment-db-storage-recall.md
Normal 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 020–022 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)
|
||||
@ -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
|
||||
|
||||
@ -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
1
src-tauri/Cargo.lock
generated
@ -6367,6 +6367,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"docx-rs",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"infer 0.15.0",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
55
src/stores/attachmentStore.ts
Normal file
55
src/stores/attachmentStore.ts
Normal 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 }),
|
||||
}));
|
||||
160
tests/unit/attachmentStore.test.ts
Normal file
160
tests/unit/attachmentStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user