tftsr-devops_investigation/src/components/PiiDiffViewer.tsx
Shaun Arman 8839075805 feat: initial implementation of TFTSR IT Triage & RCA application
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>
2026-03-14 22:36:25 -05:00

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">-&gt;</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";
}
}