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
217 lines
8.8 KiB
TypeScript
217 lines
8.8 KiB
TypeScript
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>
|
||
);
|
||
}
|