tftsr-devops_investigation/src/pages/LogUpload/index.tsx
Shaun Arman 31df73c6d0 feat: add use_datastore_upload config option for GenAI
- 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
2026-04-09 17:14:41 -05:00

471 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}