Implements Phases 1-8 of the TFTSR implementation plan. Rust backend (Tauri 2.x, src-tauri/): - Multi-provider AI: OpenAI-compatible, Anthropic, Gemini, Mistral, Ollama - PII detection engine: 11 regex patterns with overlap resolution - SQLCipher AES-256 encrypted database with 10 versioned migrations - 28 Tauri IPC commands for triage, analysis, document, and system ops - Ollama: hardware probe, model recommendations, pull/delete with events - RCA and blameless post-mortem Markdown document generators - PDF export via printpdf - Audit log: SHA-256 hash of every external data send - Integration stubs for Confluence, ServiceNow, Azure DevOps (v0.2) Frontend (React 18 + TypeScript + Vite, src/): - 9 pages: full triage workflow NewIssue→LogUpload→Triage→Resolution→RCA→Postmortem→History+Settings - 7 components: ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI primitives - 3 Zustand stores: session, settings (persisted), history - Type-safe tauriCommands.ts matching Rust backend types exactly - 8 IT domain system prompts (Linux, Windows, Network, K8s, DB, Virt, HW, Obs) DevOps: - .woodpecker/test.yml: rustfmt, clippy, cargo test, tsc, vitest on every push - .woodpecker/release.yml: linux/amd64 + linux/arm64 builds, Gogs release upload Verified: - cargo check: zero errors - tsc --noEmit: zero errors - vitest run: 13/13 unit tests passing Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
import React from "react";
|
|
import type { PiiSpan } from "@/lib/tauriCommands";
|
|
import { Badge } from "@/components/ui";
|
|
|
|
interface PiiDiffViewerProps {
|
|
originalText: string;
|
|
redactedText: string;
|
|
spans: PiiSpan[];
|
|
approvedSpans: PiiSpan[];
|
|
onToggleSpan: (span: PiiSpan, approved: boolean) => void;
|
|
}
|
|
|
|
export function PiiDiffViewer({
|
|
originalText,
|
|
redactedText,
|
|
spans,
|
|
approvedSpans,
|
|
onToggleSpan,
|
|
}: PiiDiffViewerProps) {
|
|
const isApproved = (span: PiiSpan) =>
|
|
approvedSpans.some((s) => s.start === span.start && s.end === span.end);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Side-by-side diff */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Original</h4>
|
|
<div className="rounded-md border bg-card p-3 text-sm font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
|
|
{highlightSpans(originalText, spans, "original")}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Redacted</h4>
|
|
<div className="rounded-md border bg-card p-3 text-sm font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
|
|
{redactedText || <span className="text-muted-foreground italic">No redactions applied</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PII span list */}
|
|
{spans.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2">Detected PII ({spans.length} items)</h4>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{spans.map((span, idx) => (
|
|
<div
|
|
key={`${span.start}-${span.end}-${idx}`}
|
|
className="flex items-center justify-between rounded-md border p-2"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={piiTypeBadgeVariant(span.pii_type)}>
|
|
{span.pii_type}
|
|
</Badge>
|
|
<span className="text-sm font-mono truncate max-w-[200px]">
|
|
{span.original}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">-></span>
|
|
<span className="text-sm font-mono text-muted-foreground truncate max-w-[200px]">
|
|
{span.replacement}
|
|
</span>
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<span className="text-xs text-muted-foreground">
|
|
{isApproved(span) ? "Redact" : "Keep"}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={isApproved(span)}
|
|
onClick={() => onToggleSpan(span, !isApproved(span))}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
isApproved(span) ? "bg-green-600" : "bg-muted"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
|
isApproved(span) ? "translate-x-4" : "translate-x-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function highlightSpans(text: string, spans: PiiSpan[], _mode: "original" | "redacted") {
|
|
if (spans.length === 0) return text;
|
|
|
|
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
const parts: React.ReactNode[] = [];
|
|
let lastEnd = 0;
|
|
|
|
sorted.forEach((span, idx) => {
|
|
if (span.start > lastEnd) {
|
|
parts.push(text.slice(lastEnd, span.start));
|
|
}
|
|
parts.push(
|
|
<mark key={idx} className="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">
|
|
{text.slice(span.start, span.end)}
|
|
</mark>
|
|
);
|
|
lastEnd = span.end;
|
|
});
|
|
|
|
if (lastEnd < text.length) {
|
|
parts.push(text.slice(lastEnd));
|
|
}
|
|
|
|
return <>{parts}</>;
|
|
}
|
|
|
|
function piiTypeBadgeVariant(piiType: string): "default" | "secondary" | "destructive" | "outline" {
|
|
switch (piiType.toLowerCase()) {
|
|
case "email":
|
|
case "phone":
|
|
return "default";
|
|
case "ip_address":
|
|
case "hostname":
|
|
return "secondary";
|
|
case "ssn":
|
|
case "credit_card":
|
|
case "password":
|
|
return "destructive";
|
|
default:
|
|
return "outline";
|
|
}
|
|
}
|