skillguard/artifacts/skillguard/src/pages/scan-compare.tsx
amertensreplit ba9788a93c Add Skill-Fingerprint database & report comparison
Each scan gets a deterministic overall fingerprint (SHA-256 over sorted
path+fileHash pairs) plus per-file SHA-256 hashes and stored text content
(binary: hash+size only). On upload the skill is always re-scanned and
classified vs prior scans as new / identical / modified, with a per-fingerprint
check counter, a "most similar known skill" link, and a file-level diff view.

Deviations from the plan:
- Relation matching keys off shared file *paths* (Jaccard over paths, tie-break
  on hashes), not hash-Jaccard alone, which is always 0 for single-file edits
  (text paste = one SKILL.md) and would mis-class every edited single-file skill
  as "new". Similarity is content-aware: identical files = 1.0, changed text
  files use line-level LCS ratio, added/removed/changed-binary = 0.
- parseText no longer uses the display name as the file path (fixed "SKILL.md")
  so identical pastes with different names are "identical", not "modified".

Backend: skillFingerprint.ts, lineDiff.ts (+lineSimilarity), skillParser.ts
(per-file hash+isBinary), routes/scans.ts (computeRelation, content similarity,
checkCount, comparedScan, GET /scans/:id/compare/:otherId). DB: scans
fingerprint/relation/similarity/comparedScanId (+index), scan_files hash/content.
API spec + orval codegen regenerated. UI: fingerprint card + compare link on
report, relation badges in history, new /vergleich/:id/:otherId page with
side-by-side summaries and expandable line diff. German UI, no emojis.

Verified end-to-end against the running API and screenshotted both UI pages;
test data cleaned up afterward.

Code-review fix: relation classification no longer relies on path-Jaccard
(every text paste shares path SKILL.md, so unrelated pastes were falsely
linked as "modified"). computeRelation now selects the candidate by
content-aware similarity and only returns "modified" when similarity >= 40
or a file is byte-identical; otherwise "new". Updated OpenAPI similarity
description; removed now-unused jaccard import.

Replit-Task-Id: 79a8e472-6635-493c-8995-3233ba7df75c
2026-06-10 19:34:46 +00:00

217 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { useRoute, Link } from "wouter";
import { useCompareScans, getCompareScansQueryKey } from "@workspace/api-client-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { VerdictBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format";
import { ShieldQuestion, ArrowLeft, FileCode, ChevronDown, ChevronRight } from "lucide-react";
import type { ScanComparisonSide, ScanFileDiff } from "@workspace/api-client-react";
const STATUS_LABELS: Record<string, string> = {
unchanged: "Unverändert",
modified: "Geändert",
added: "Neu",
removed: "Entfernt",
};
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "unchanged":
return <Badge variant="outline" className="text-muted-foreground">Unverändert</Badge>;
case "modified":
return <Badge className="bg-amber-500 hover:bg-amber-600 text-white border-transparent">Geändert</Badge>;
case "added":
return <Badge className="bg-emerald-500 hover:bg-emerald-600 text-white border-transparent">Neu</Badge>;
case "removed":
return <Badge className="bg-rose-500 hover:bg-rose-600 text-white border-transparent">Entfernt</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
}
function SkillSummaryCard({ side, label }: { side: ScanComparisonSide; label: string }) {
return (
<Card>
<CardHeader className="pb-3">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-lg flex items-center gap-2 flex-wrap">
<Link href={`/berichte/${side.id}`} className="hover:underline">
{side.name || `Scan #${side.id}`}
</Link>
<VerdictBadge verdict={side.verdict} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko-Score</span>
<span className="font-mono font-bold">{side.riskScore} / 100</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Dateien</span>
<span className="font-mono">{side.fileCount}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Erstellt</span>
<span>{formatDate(side.createdAt)}</span>
</div>
<div className="flex flex-col gap-1 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wider">Fingerprint</span>
<code className="font-mono text-xs break-all bg-muted rounded px-2 py-1.5" title={side.fingerprint}>
{side.fingerprint ? `${side.fingerprint.slice(0, 24)}` : "-"}
</code>
</div>
</CardContent>
</Card>
);
}
function FileDiffRow({ file }: { file: ScanFileDiff }) {
const [open, setOpen] = useState(false);
const canExpand = file.status === "modified" && file.lineDiff && file.lineDiff.length > 0;
return (
<div className="border-b last:border-0">
<button
type="button"
onClick={() => canExpand && setOpen((o) => !o)}
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${canExpand ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"}`}
>
{canExpand ? (
open ? <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<span className="w-4 shrink-0" />
)}
<FileCode className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="font-mono text-sm flex-1 break-all">{file.path}</span>
{file.status === "modified" && !file.lineDiff && (file.previousHasContent === false || file.currentHasContent === false) && (
<Badge variant="outline" className="text-[10px]">binär</Badge>
)}
<StatusBadge status={file.status} />
</button>
{open && canExpand && (
<div className="bg-slate-950 overflow-x-auto">
<table className="w-full font-mono text-xs">
<tbody>
{file.lineDiff!.map((line, i) => {
const bg =
line.type === "add" ? "bg-emerald-950/60" :
line.type === "remove" ? "bg-rose-950/60" : "";
const sign = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
const textColor =
line.type === "add" ? "text-emerald-300" :
line.type === "remove" ? "text-rose-300" : "text-slate-400";
return (
<tr key={i} className={bg}>
<td className="px-2 py-0.5 text-right text-slate-600 select-none w-12 align-top">{line.previousLine ?? ""}</td>
<td className="px-2 py-0.5 text-right text-slate-600 select-none w-12 align-top">{line.currentLine ?? ""}</td>
<td className={`px-2 py-0.5 select-none w-4 align-top ${textColor}`}>{sign}</td>
<td className={`px-2 py-0.5 whitespace-pre-wrap break-all ${textColor}`}>{line.text || " "}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export default function ScanCompare() {
const [, params] = useRoute("/vergleich/:id/:otherId");
const id = Number(params?.id);
const otherId = Number(params?.otherId);
const valid = Number.isFinite(id) && Number.isFinite(otherId) && id > 0 && otherId > 0;
const { data, isLoading, error } = useCompareScans(id, otherId, {
query: {
enabled: valid,
queryKey: getCompareScansQueryKey(id, otherId),
},
});
if (isLoading || (!data && !error && valid)) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-1/3" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Skeleton className="h-56 w-full" />
<Skeleton className="h-56 w-full" />
</div>
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !data || !valid) {
return (
<div className="p-8 text-center bg-destructive/10 rounded-lg text-destructive">
<ShieldQuestion className="w-12 h-12 mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-bold">Vergleich nicht möglich</h2>
<p>Einer der beiden Scans existiert nicht oder konnte nicht geladen werden.</p>
</div>
);
}
const counts = data.files.reduce(
(acc, f) => {
acc[f.status] = (acc[f.status] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return (
<div className="space-y-6 pb-12">
<div className="flex flex-col gap-2">
<Button asChild variant="ghost" size="sm" className="self-start gap-2 -ml-2">
<Link href={`/berichte/${data.current.id}`}>
<ArrowLeft className="w-4 h-4" />
Zurück zum Bericht
</Link>
</Button>
<h1 className="text-3xl font-bold tracking-tight">Skill-Vergleich</h1>
<p className="text-muted-foreground">
Gegenüberstellung des ursprünglich gespeicherten Skills und der aktuell geprüften Variante inklusive Datei-Status und zeilenweisem Diff.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SkillSummaryCard side={data.previous} label="Skill 1 Bekannt (aus der Datenbank)" />
<SkillSummaryCard side={data.current} label="Skill 2 Aktuell geprüft" />
</div>
<Card>
<CardHeader>
<CardTitle>Datei-Vergleich</CardTitle>
<CardDescription className="flex flex-wrap gap-2 pt-1">
{(["unchanged", "modified", "added", "removed"] as const).map((s) =>
counts[s] ? (
<span key={s} className="flex items-center gap-1.5">
<StatusBadge status={s} />
<span className="text-sm">{counts[s]}</span>
</span>
) : null,
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
{data.files.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">Keine Dateien zum Vergleichen.</div>
) : (
data.files.map((file) => <FileDiffRow key={file.path} file={file} />)
)}
</div>
<p className="text-xs text-muted-foreground mt-3">
Geänderte Textdateien lassen sich aufklappen, um den zeilenweisen Unterschied anzuzeigen.
</p>
</CardContent>
</Card>
</div>
);
}