tftsr-devops_investigation/src/pages/LogUpload/index.tsx
Shaun Arman d331e9c7c7
All checks were successful
Test / frontend-tests (pull_request) Successful in 1m38s
Test / frontend-typecheck (pull_request) Successful in 1m40s
Test / rust-fmt-check (pull_request) Successful in 14m53s
Test / rust-tests (pull_request) Successful in 18m2s
Test / rust-clippy (pull_request) Successful in 15m46s
PR Review Automation / review (pull_request) Successful in 3m46s
fix(ui): correct font contrast and background colors in dark mode
Replace hardcoded light-mode Tailwind colors with dark: variants
across six components. Issues that broke readability:

- PiiDiffViewer / Security: toggle knob was bg-white (invisible on
  bg-muted in dark mode) -> bg-background
- ImageGallery: thumbnail container, filename labels, alert banners,
  and modal chrome all used hardcoded gray/white backgrounds with dark
  text; added full dark: variants throughout
- ShellExecution TIER_CONFIG: tier cards used bg-green/yellow/red-50
  (near-white) with dark text; added dark:bg-*-950/30 backgrounds and
  light text for all three tiers
- ShellApprovalModal: tier 2 badge hardcoded bg-yellow-50/text-yellow-700;
  added dark: variants
- LogUpload: PII warning alert used bg-amber-50/text-amber-800; added
  dark:bg-amber-900/20 and lighter text for dark mode
2026-06-07 21:30:50 -05:00

438 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, 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,
listImageAttachmentsCmd,
deleteImageAttachmentCmd,
type LogFile,
type PiiSpan,
type PiiDetectionResult,
type ImageAttachment,
} from "@/lib/tauriCommands";
import ImageGallery from "@/components/ImageGallery";
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 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;
void 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 handleImagesUpload = useCallback(async (imageFiles: File[]) => {
if (!id || imageFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
const uploaded = await Promise.all(
imageFiles.map(async (file) => {
const result = await uploadImageAttachmentCmd(id, file.name);
return result;
})
);
setImages((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(String(err));
} finally {
setIsUploading(false);
}
}, [id]);
const handleImageDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/"));
if (imageFiles.length > 0) {
handleImagesUpload(imageFiles);
}
},
[handleImagesUpload]
);
const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selected = Array.from(e.target.files).filter((f) => f.type.startsWith("image/"));
if (selected.length > 0) {
handleImagesUpload(selected);
}
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
void e.clipboardData?.items;
const imageItems = Array.from(e.clipboardData?.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 handleDeleteImage = async (image: ImageAttachment) => {
try {
await deleteImageAttachmentCmd(image.id);
setImages((prev) => prev.filter((img) => img.id !== image.id));
} catch (err) {
setError(String(err));
}
};
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,.out,.err,.syslog,.journal,.yaml,.yml,.json,.toml,.xml,.ini,.cfg,.conf,.config,.env,.properties,.md,.markdown,.rst,.csv,.tsv,.ndjson,.jsonl,.sql,.sh,.bash,.zsh,.py,.js,.ts,.rb,.go,.rs,.java,.html,.htm,.css,.diff,.patch,.pdf,.docx,.doc,.rtf,.pcap,.pcapng,.cap"
/>
<details className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<summary className="cursor-pointer hover:text-gray-700 dark:hover:text-gray-200">
Supported formats
</summary>
<div className="mt-1 pl-3 space-y-1">
<div><span className="font-medium">Logs &amp; text:</span> .log, .txt, .out, .err, .syslog, .journal</div>
<div><span className="font-medium">Config &amp; markup:</span> .yaml, .yml, .json, .toml, .xml, .ini, .cfg, .conf, .env, .properties</div>
<div><span className="font-medium">Documents:</span> .pdf, .docx, .doc, .md, .rst, .rtf</div>
<div><span className="font-medium">Data:</span> .csv, .tsv, .ndjson, .jsonl, .sql</div>
<div><span className="font-medium">Code &amp; scripts:</span> .sh, .bash, .zsh, .py, .js, .ts, .rb, .go, .rs, .java, .html, .css, .diff, .patch</div>
<div><span className="font-medium">Network captures:</span> .pcap, .pcapng, .cap (requires tshark or tcpdump)</div>
<p className="mt-1 italic">Binary formats (PDF, DOCX, PCAP) will have their text extracted automatically. XLSX/XLS files are NOT supported - export as CSV instead.</p>
</div>
</details>
</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 dark:bg-amber-900/20 dark:border-amber-700">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 inline mr-2" />
<span className="text-sm text-amber-800 dark:text-amber-300">
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>
);
}