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
|
## 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`
|
**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
|
- 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)
|
- 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
|
## 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.
|
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
|
## 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.
|
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
|
## AI Commands
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -6367,6 +6367,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"docx-rs",
|
"docx-rs",
|
||||||
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"infer 0.15.0",
|
"infer 0.15.0",
|
||||||
|
|||||||
@ -53,6 +53,7 @@ rmcp = { version = "1.7.0", features = [
|
|||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
] }
|
] }
|
||||||
|
flate2 = { version = "1", features = ["rust_backend"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Read as IoRead;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tracing::warn;
|
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::pii::{self, PiiDetectionResult, PiiDetector, RedactedLogFile};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@ -55,6 +60,30 @@ const SAFE_TEXT_EXTENSIONS: &[&str] = &[
|
|||||||
|
|
||||||
const SAFE_BINARY_EXTENSIONS: &[&str] = &["pdf", "docx", "doc", "xlsx", "xls"];
|
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 {
|
pub fn is_safe_file(path: &Path) -> bool {
|
||||||
let ext = path
|
let ext = path
|
||||||
.extension()
|
.extension()
|
||||||
@ -229,10 +258,13 @@ pub async fn upload_log_file(
|
|||||||
..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())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
log_file.id,
|
log_file.id,
|
||||||
log_file.issue_id,
|
log_file.issue_id,
|
||||||
@ -243,6 +275,7 @@ pub async fn upload_log_file(
|
|||||||
log_file.content_hash,
|
log_file.content_hash,
|
||||||
log_file.uploaded_at,
|
log_file.uploaded_at,
|
||||||
log_file.redacted as i32,
|
log_file.redacted as i32,
|
||||||
|
compressed,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
|
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
|
||||||
@ -319,10 +352,13 @@ pub async fn upload_log_file_by_content(
|
|||||||
..log_file
|
..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())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
log_file.id,
|
log_file.id,
|
||||||
log_file.issue_id,
|
log_file.issue_id,
|
||||||
@ -333,6 +369,7 @@ pub async fn upload_log_file_by_content(
|
|||||||
log_file.content_hash,
|
log_file.content_hash,
|
||||||
log_file.uploaded_at,
|
log_file.uploaded_at,
|
||||||
log_file.redacted as i32,
|
log_file.redacted as i32,
|
||||||
|
compressed,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -581,4 +682,56 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("not yet supported"));
|
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 tauri::State;
|
||||||
|
|
||||||
use crate::audit::log::write_audit_event;
|
use crate::audit::log::write_audit_event;
|
||||||
use crate::db::models::{AuditEntry, ImageAttachment};
|
use crate::db::models::{AuditEntry, ImageAttachment, ImageAttachmentSummary};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
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())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
db.execute(
|
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) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
attachment.id,
|
attachment.id,
|
||||||
attachment.issue_id,
|
attachment.issue_id,
|
||||||
@ -95,6 +95,7 @@ pub async fn upload_image_attachment(
|
|||||||
attachment.uploaded_at,
|
attachment.uploaded_at,
|
||||||
attachment.pii_warning_acknowledged as i32,
|
attachment.pii_warning_acknowledged as i32,
|
||||||
attachment.is_paste as i32,
|
attachment.is_paste as i32,
|
||||||
|
content,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
|
.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)
|
.decode(data_part)
|
||||||
.map_err(|_| "Failed to decode base64 image data")?;
|
.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 content_hash = format!("{:x}", sha2::Sha256::digest(&decoded));
|
||||||
let file_size = decoded.len() as i64;
|
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())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
db.execute(
|
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) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
attachment.id,
|
attachment.id,
|
||||||
attachment.issue_id,
|
attachment.issue_id,
|
||||||
@ -181,6 +189,7 @@ pub async fn upload_image_attachment_by_content(
|
|||||||
attachment.uploaded_at,
|
attachment.uploaded_at,
|
||||||
attachment.pii_warning_acknowledged as i32,
|
attachment.pii_warning_acknowledged as i32,
|
||||||
attachment.is_paste as i32,
|
attachment.is_paste as i32,
|
||||||
|
decoded,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
|
.map_err(|_| "Failed to store uploaded image metadata".to_string())?;
|
||||||
@ -229,6 +238,13 @@ pub async fn upload_paste_image(
|
|||||||
.decode(data_part)
|
.decode(data_part)
|
||||||
.map_err(|_| "Failed to decode base64 image data")?;
|
.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 content_hash = format!("{:x}", sha2::Sha256::digest(&decoded));
|
||||||
let file_size = decoded.len() as i64;
|
let file_size = decoded.len() as i64;
|
||||||
let file_name = format!("pasted-image-{}.png", uuid::Uuid::now_v7());
|
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())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
db.execute(
|
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) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
attachment.id,
|
attachment.id,
|
||||||
attachment.issue_id,
|
attachment.issue_id,
|
||||||
@ -267,6 +283,7 @@ pub async fn upload_paste_image(
|
|||||||
attachment.uploaded_at,
|
attachment.uploaded_at,
|
||||||
attachment.pii_warning_acknowledged as i32,
|
attachment.pii_warning_acknowledged as i32,
|
||||||
attachment.is_paste as i32,
|
attachment.is_paste as i32,
|
||||||
|
decoded,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|_| "Failed to store pasted image metadata".to_string())?;
|
.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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -605,4 +693,13 @@ mod tests {
|
|||||||
assert!(is_supported_image_format("image/bmp"));
|
assert!(is_supported_image_format("image/bmp"));
|
||||||
assert!(!is_supported_image_format("text/plain"));
|
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'))
|
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 {
|
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")
|
} else if name.ends_with("_add_use_datastore_upload")
|
||||||
|| name.ends_with("_add_created_at")
|
|| 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)
|
// Use execute for ALTER TABLE (SQLite only allows one statement per command)
|
||||||
// Skip error if column already exists (SQLITE_ERROR with "duplicate column name")
|
// Skip error if column already exists (SQLITE_ERROR with "duplicate column name")
|
||||||
@ -1101,4 +1127,110 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(applied, 1, "019 should only be recorded once");
|
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,
|
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 {
|
impl ImageAttachment {
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
|||||||
@ -86,11 +86,15 @@ pub fn run() {
|
|||||||
commands::analysis::upload_log_file_by_content,
|
commands::analysis::upload_log_file_by_content,
|
||||||
commands::analysis::detect_pii,
|
commands::analysis::detect_pii,
|
||||||
commands::analysis::apply_redactions,
|
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,
|
||||||
commands::image::upload_image_attachment_by_content,
|
commands::image::upload_image_attachment_by_content,
|
||||||
commands::image::list_image_attachments,
|
commands::image::list_image_attachments,
|
||||||
commands::image::delete_image_attachment,
|
commands::image::delete_image_attachment,
|
||||||
commands::image::upload_paste_image,
|
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,
|
||||||
commands::image::upload_file_to_datastore_any,
|
commands::image::upload_file_to_datastore_any,
|
||||||
// AI
|
// AI
|
||||||
|
|||||||
@ -615,3 +615,52 @@ export const clearSudoPasswordCmd = () =>
|
|||||||
|
|
||||||
export const getAppVersionCmd = () =>
|
export const getAppVersionCmd = () =>
|
||||||
invoke<string>("get_app_version");
|
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 React, { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Search, Download, ExternalLink } from "lucide-react";
|
import { Search, Download, ExternalLink, FileText, Image, Eye } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -14,10 +14,46 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { useHistoryStore } from "@/stores/historyStore";
|
import { useHistoryStore } from "@/stores/historyStore";
|
||||||
|
import { useAttachmentStore } from "@/stores/attachmentStore";
|
||||||
|
import { getLogFileContentCmd, getImageAttachmentDataCmd } from "@/lib/tauriCommands";
|
||||||
import { DOMAINS } from "@/lib/domainPrompts";
|
import { DOMAINS } from "@/lib/domainPrompts";
|
||||||
|
|
||||||
|
type TabId = "issues" | "attachments";
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const navigate = useNavigate();
|
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 } =
|
const { issues, isLoading, searchQuery, loadIssues, searchIssues, setSearchQuery } =
|
||||||
useHistoryStore();
|
useHistoryStore();
|
||||||
|
|
||||||
@ -74,9 +110,7 @@ export default function History() {
|
|||||||
sortField === field ? (sortAsc ? " ↑" : " ↓") : "";
|
sortField === field ? (sortAsc ? " ↑" : " ↓") : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<>
|
||||||
<h1 className="text-3xl font-bold">History</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
@ -213,32 +247,324 @@ export default function History() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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" {
|
function severityVariant(severity: string): "default" | "destructive" | "secondary" | "outline" {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "P1":
|
case "P1": return "destructive";
|
||||||
return "destructive";
|
case "P2": return "default";
|
||||||
case "P2":
|
case "P3": return "secondary";
|
||||||
return "default";
|
default: return "outline";
|
||||||
case "P3":
|
|
||||||
return "secondary";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusVariant(status: string): "default" | "destructive" | "secondary" | "outline" {
|
function statusVariant(status: string): "default" | "destructive" | "secondary" | "outline" {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "open":
|
case "open": return "default";
|
||||||
return "default";
|
case "triaging": return "secondary";
|
||||||
case "triaging":
|
case "resolved": return "outline";
|
||||||
return "secondary";
|
default: return "outline";
|
||||||
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