- Add use_datastore_upload boolean to ProviderConfig (Rust and TypeScript) - Add config field to AI provider form UI toggle - Update upload_file_to_datastore to use correct auth headers - Fix LogUpload file upload to use file dialog for paths - Support LogFile to ImageAttachment conversion - Update Cargo.toml for multipart support in reqwest
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
import { Upload, File, Trash2, ShieldCheck, AlertTriangle, Image as ImageIcon } from "lucide-react";
|
||
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@/components/ui";
|
||
import { PiiDiffViewer } from "@/components/PiiDiffViewer";
|
||
import { useSessionStore } from "@/stores/sessionStore";
|
||
import {
|
||
uploadLogFileCmd,
|
||
detectPiiCmd,
|
||
uploadImageAttachmentCmd,
|
||
uploadPasteImageCmd,
|
||
uploadFileToDatastoreCmd,
|
||
listImageAttachmentsCmd,
|
||
deleteImageAttachmentCmd,
|
||
type LogFile,
|
||
type PiiSpan,
|
||
type PiiDetectionResult,
|
||
type ImageAttachment,
|
||
} from "@/lib/tauriCommands";
|
||
import ImageGallery from "@/components/ImageGallery";
|
||
import { open } from "@tauri-apps/plugin-dialog";
|
||
|
||
export default function LogUpload() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
const { piiSpans, approvedRedactions, setPiiSpans, setApprovedRedactions } = useSessionStore();
|
||
|
||
const [files, setFiles] = useState<{ file: File; uploaded?: LogFile }[]>([]);
|
||
const [images, setImages] = useState<ImageAttachment[]>([]);
|
||
const [piiResult, setPiiResult] = useState<PiiDetectionResult | null>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isDetecting, setIsDetecting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleDrop = useCallback(
|
||
(e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||
setFiles((prev) => [...prev, ...droppedFiles.map((f) => ({ file: f }))]);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files) {
|
||
const selected = Array.from(e.target.files);
|
||
setFiles((prev) => [...prev, ...selected.map((f) => ({ file: f }))]);
|
||
}
|
||
};
|
||
|
||
const removeFile = (index: number) => {
|
||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const handleUpload = async () => {
|
||
if (!id || files.length === 0) return;
|
||
setIsUploading(true);
|
||
setError(null);
|
||
try {
|
||
const uploaded = await Promise.all(
|
||
files.map(async (entry) => {
|
||
if (entry.uploaded) return entry;
|
||
const content = await entry.file.text();
|
||
const logFile = await uploadLogFileCmd(id, entry.file.name);
|
||
return { ...entry, uploaded: logFile };
|
||
})
|
||
);
|
||
setFiles(uploaded);
|
||
} catch (err) {
|
||
setError(String(err));
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleDetectPii = async () => {
|
||
const allContent = files
|
||
.map((f) => f.uploaded?.file_name)
|
||
.filter(Boolean)
|
||
.join("\n---\n");
|
||
if (!allContent) return;
|
||
setIsDetecting(true);
|
||
setError(null);
|
||
try {
|
||
const result = files[0]?.uploaded ? await detectPiiCmd(files[0].uploaded.id) : null;
|
||
setPiiResult(result);
|
||
if (result) { setPiiSpans(result.detections);
|
||
setApprovedRedactions(result.detections); };
|
||
} catch (err) {
|
||
setError(String(err));
|
||
} finally {
|
||
setIsDetecting(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleSpan = (span: PiiSpan, approved: boolean) => {
|
||
if (approved) {
|
||
setApprovedRedactions([...approvedRedactions, span]);
|
||
} else {
|
||
setApprovedRedactions(
|
||
approvedRedactions.filter(
|
||
(s) => !(s.start === span.start && s.end === span.end)
|
||
)
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleImageDrop = useCallback(
|
||
async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
// Use file dialog to get actual paths (drag-and-drop doesn't give paths in Tauri v2)
|
||
try {
|
||
const selected = await open({
|
||
multiple: true,
|
||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] }],
|
||
});
|
||
if (!selected) return;
|
||
const paths = Array.isArray(selected) ? selected : [selected];
|
||
|
||
if (paths.length > 0) {
|
||
handleImagesUpload(paths as unknown as File[]);
|
||
}
|
||
} catch (err) {
|
||
setError(`Attachment failed: ${String(err)}`);
|
||
}
|
||
},
|
||
[id]
|
||
);
|
||
|
||
const handleImageFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files.length > 0) {
|
||
try {
|
||
const selected = await open({
|
||
multiple: true,
|
||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] }],
|
||
});
|
||
if (selected) {
|
||
const paths = Array.isArray(selected) ? selected : [selected];
|
||
|
||
if (paths.length > 0) {
|
||
handleImagesUpload(paths as unknown as File[]);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setError(`Attachment failed: ${String(err)}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePaste = useCallback(
|
||
async (e: React.ClipboardEvent) => {
|
||
const items = e.clipboardData?.items;
|
||
const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : [];
|
||
|
||
for (const item of imageItems) {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
const base64Data = reader.result as string;
|
||
try {
|
||
const result = await uploadPasteImageCmd(id || "", base64Data, file.type);
|
||
setImages((prev) => [...prev, result]);
|
||
} catch (err) {
|
||
setError(String(err));
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}
|
||
},
|
||
[id]
|
||
);
|
||
|
||
const handleImagesUpload = async (imageFiles: File[]) => {
|
||
if (!id || imageFiles.length === 0) return;
|
||
|
||
setIsUploading(true);
|
||
setError(null);
|
||
try {
|
||
const uploaded: ImageAttachment[] = await Promise.all(
|
||
imageFiles.map(async (file) => {
|
||
// Extract the path from the file name (which contains the full path from file dialog)
|
||
const filePath = file.name;
|
||
// Get just the filename for display
|
||
const fileName = filePath.split(/[\/\\]/).pop() || "unknown";
|
||
// Use uploadLogFileCmd which properly handles file paths
|
||
const result = await uploadLogFileCmd(id, filePath);
|
||
// Convert LogFile to ImageAttachment for the state
|
||
return {
|
||
id: result.id,
|
||
issue_id: result.issue_id,
|
||
file_name: result.file_name,
|
||
file_path: result.file_path,
|
||
file_size: result.file_size,
|
||
mime_type: result.mime_type,
|
||
upload_hash: result.content_hash,
|
||
uploaded_at: result.uploaded_at,
|
||
pii_warning_acknowledged: true,
|
||
is_paste: false,
|
||
};
|
||
})
|
||
);
|
||
setImages((prev) => [...prev, ...uploaded]);
|
||
} catch (err) {
|
||
setError(String(err));
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteImage = async (image: ImageAttachment) => {
|
||
try {
|
||
await deleteImageAttachmentCmd(image.id);
|
||
setImages((prev) => prev.filter((img) => img.id !== image.id));
|
||
} catch (err) {
|
||
setError(String(err));
|
||
}
|
||
};
|
||
|
||
const fileToBase64 = (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result as string);
|
||
reader.onerror = (err) => reject(err);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
};
|
||
|
||
|
||
const allUploaded = files.length > 0 && files.every((f) => f.uploaded);
|
||
const piiReviewed = piiResult != null;
|
||
|
||
useEffect(() => {
|
||
const handleGlobalPaste = (e: ClipboardEvent) => {
|
||
if (document.activeElement?.tagName === "INPUT" ||
|
||
document.activeElement?.tagName === "TEXTAREA" ||
|
||
(document.activeElement as HTMLElement)?.isContentEditable || false) {
|
||
return;
|
||
}
|
||
|
||
const items = e.clipboardData?.items;
|
||
const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : [];
|
||
|
||
for (const item of imageItems) {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
e.preventDefault();
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
const base64Data = reader.result as string;
|
||
try {
|
||
const result = await uploadPasteImageCmd(id || "", base64Data, file.type);
|
||
setImages((prev) => [...prev, result]);
|
||
} catch (err) {
|
||
setError(String(err));
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener("paste", handleGlobalPaste);
|
||
return () => window.removeEventListener("paste", handleGlobalPaste);
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
if (id) {
|
||
listImageAttachmentsCmd(id).then(setImages).catch(setError);
|
||
}
|
||
}, [id]);
|
||
|
||
return (
|
||
<div className="p-6 space-y-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold">Upload Logs</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Upload log files for PII detection and redaction before triage.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Drop zone */}
|
||
<div
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={handleDrop}
|
||
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer"
|
||
onClick={() => document.getElementById("file-input")?.click()}
|
||
>
|
||
<Upload className="w-8 h-8 mx-auto text-muted-foreground mb-2" />
|
||
<p className="text-sm text-muted-foreground">
|
||
Drag and drop log files here, or click to browse
|
||
</p>
|
||
<input
|
||
id="file-input"
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
accept=".log,.txt,.json,.csv,.xml,.yaml,.yml"
|
||
/>
|
||
</div>
|
||
|
||
{/* File list */}
|
||
{files.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Files ({files.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{files.map((entry, idx) => (
|
||
<div key={idx} className="flex items-center justify-between rounded-md border p-2">
|
||
<div className="flex items-center gap-2">
|
||
<File className="w-4 h-4 text-muted-foreground" />
|
||
<span className="text-sm">{entry.file.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
({(entry.file.size / 1024).toFixed(1)} KB)
|
||
</span>
|
||
{entry.uploaded && (
|
||
<Badge variant="outline">Uploaded</Badge>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => removeFile(idx)}
|
||
className="text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{!allUploaded && (
|
||
<Button onClick={handleUpload} disabled={isUploading} className="mt-2">
|
||
{isUploading ? "Uploading..." : "Upload Files"}
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Image Upload */}
|
||
{id && (
|
||
<>
|
||
<div>
|
||
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||
<ImageIcon className="w-6 h-6" />
|
||
Image Attachments
|
||
</h2>
|
||
<p className="text-muted-foreground mt-1">
|
||
Upload or paste screenshots and images.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Image drop zone */}
|
||
<div
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={handleImageDrop}
|
||
className="border-2 border-dashed border-primary/30 rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer bg-primary/5"
|
||
onClick={() => document.getElementById("image-input")?.click()}
|
||
>
|
||
<Upload className="w-8 h-8 mx-auto text-primary mb-2" />
|
||
<p className="text-sm text-muted-foreground">
|
||
Drag and drop images here, or click to browse
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-2">
|
||
Supported: PNG, JPEG, GIF, WebP, SVG
|
||
</p>
|
||
<input
|
||
id="image-input"
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleImageFileSelect}
|
||
/>
|
||
</div>
|
||
|
||
{/* Paste button */}
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
document.execCommand("paste");
|
||
}}
|
||
variant="secondary"
|
||
>
|
||
Paste from Clipboard
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
Use Ctrl+V / Cmd+V or the button above to paste images
|
||
</span>
|
||
</div>
|
||
|
||
{/* PII warning for images */}
|
||
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
|
||
<AlertTriangle className="w-5 h-5 text-amber-600 inline mr-2" />
|
||
<span className="text-sm text-amber-800">
|
||
⚠️ PII cannot be automatically redacted from images. Use at your own risk.
|
||
</span>
|
||
</div>
|
||
|
||
{/* Image Gallery */}
|
||
{images.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<ImageIcon className="w-5 h-5" />
|
||
Attached Images ({images.length})
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ImageGallery
|
||
images={images}
|
||
onDelete={handleDeleteImage}
|
||
showWarning={false}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* PII Detection */}
|
||
{allUploaded && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<ShieldCheck className="w-5 h-5" />
|
||
PII Detection
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{!piiResult && (
|
||
<Button onClick={handleDetectPii} disabled={isDetecting}>
|
||
{isDetecting ? "Detecting PII..." : "Detect PII"}
|
||
</Button>
|
||
)}
|
||
{piiResult && (
|
||
<PiiDiffViewer
|
||
originalText="[original log content]"
|
||
redactedText="[redacted log content]"
|
||
spans={piiSpans}
|
||
approvedSpans={approvedRedactions}
|
||
onToggleSpan={handleToggleSpan}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div className="text-sm text-destructive bg-destructive/10 rounded-md p-3">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Continue */}
|
||
<Button
|
||
onClick={() => navigate(`/issue/${id}/triage`)}
|
||
disabled={!piiReviewed}
|
||
className="w-full"
|
||
size="lg"
|
||
>
|
||
Continue to Triage
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|