Unpack .skill files & show folder tree with copyable hashes
Original task (#28): Treat uploaded .skill files like ZIP archives, extract their contents into "Geprüfte Dateien", render the folder structure as a tree, and make the full SHA-256 copyable. Backend (artifacts/api-server): - skillParser.ts: added looksLikeZip() (sniffs PK\x03\x04 signature) and a new parseUpload(filename, buffer) entry point. It extracts when the buffer has a ZIP signature OR a .zip/.skill extension; falls back cleanly to single-file handling when the archive is invalid/empty. Real archives (signature present) still surface limit/corruption errors, so existing ZIP protections (file count, total/per-file size, skipped system dirs) stay in force. - routes/scans.ts: both "zip" and "file" sources now route through parseUpload, so a .skill works whether uploaded via the ZIP area or the single-file area. - skillParser.test.ts: added a parseUpload describe block (extraction, signature detection, .zip via single-file path, invalid-.skill fallback, plain file, limit propagation, empty-archive fallback). 32 parser tests / 66 total pass. Frontend (artifacts/skillguard): - scan-report.tsx: replaced the flat files table with a FilesTree component that derives a folder tree from file paths (folders as nodes, files nested/indented) and adds a copy-to-clipboard button for the full SHA-256 next to the short hash. Type/language/size/binary indicators preserved. - scan-form.tsx: ZIP area now accepts .skill, with updated label/hint. Note: skillguard typecheck initially failed with phantom "property does not exist on ScanDetail" errors due to stale api-client-react dist declarations (project reference). Ran `pnpm run typecheck:libs` to rebuild composite libs; typecheck then passes. Documented in .agents/memory. Verified end-to-end: a .skill upload extracted into 4 files; an invalid .skill fell back to a single file. Test scans cleaned up afterwards. Rebase resolution: - Conflict in scan-report.tsx imports only: main added `ShieldAlert`, this task added `Folder, File as FileIcon, Copy, Check`. Merged both into one import. Replit-Task-Id: 72b2cacc-11eb-412b-82fd-7d5d0cf8f2a4
This commit is contained in:
parent
fc4d0d9d28
commit
e90a51af50
6 changed files with 267 additions and 53 deletions
13
.agents/memory/api-client-codegen-staleness.md
Normal file
13
.agents/memory/api-client-codegen-staleness.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
name: api-client-react project-reference staleness
|
||||||
|
description: Why skillguard typecheck reports phantom "property does not exist on ScanDetail" errors and how to fix.
|
||||||
|
---
|
||||||
|
Frontend artifacts (e.g. `artifacts/skillguard`) consume `@workspace/api-client-react` via a **TypeScript project reference** (composite). tsc resolves the API types from that package's emitted `dist/*.d.ts`, NOT directly from its `src`.
|
||||||
|
|
||||||
|
**Symptom:** `pnpm tsc --noEmit` in an artifact reports errors like `Property 'description'/'relation'/'comparedScan' does not exist on type 'ScanDetail'` even though those fields clearly exist in `lib/api-client-react/src/generated/api.schemas.ts`. The `dist/*.d.ts` is stale relative to the regenerated source.
|
||||||
|
|
||||||
|
**Fix:** run `pnpm run typecheck:libs` from the repo root (it runs `tsc --build`, which rebuilds composite lib declarations). Then the artifact typecheck passes.
|
||||||
|
|
||||||
|
**Why:** after the OpenAPI codegen updates `src`, the composite project's `dist` declarations must be rebuilt in lockstep or every downstream artifact typechecks against the old shape.
|
||||||
|
|
||||||
|
**How to apply:** whenever an artifact typecheck fails on api-client types that you can confirm exist in the client's `src`, rebuild libs first before assuming the code is wrong.
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
parseSingleFile,
|
parseSingleFile,
|
||||||
parseText,
|
parseText,
|
||||||
parseZip,
|
parseZip,
|
||||||
|
parseUpload,
|
||||||
deriveScanName,
|
deriveScanName,
|
||||||
} from "./skillParser";
|
} from "./skillParser";
|
||||||
import { hashBytes } from "./skillFingerprint";
|
import { hashBytes } from "./skillFingerprint";
|
||||||
|
|
@ -190,6 +191,68 @@ describe("parseZip", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseUpload", () => {
|
||||||
|
it("extracts a .skill file (a ZIP container) into its individual files", () => {
|
||||||
|
const zip = zipSync({
|
||||||
|
"SKILL.md": strToU8("# My Skill\n"),
|
||||||
|
"scripts/run.sh": strToU8("#!/bin/sh\necho hi\n"),
|
||||||
|
});
|
||||||
|
const files = parseUpload("my-skill.skill", Buffer.from(zip));
|
||||||
|
const paths = files.map((f) => f.path).sort();
|
||||||
|
expect(paths).toEqual(["SKILL.md", "scripts/run.sh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a ZIP container by signature even with a non-archive name", () => {
|
||||||
|
const zip = zipSync({ "SKILL.md": strToU8("# Detected by bytes") });
|
||||||
|
const files = parseUpload("export.dat", Buffer.from(zip));
|
||||||
|
expect(files.map((f) => f.path)).toEqual(["SKILL.md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a .zip uploaded through the single-file path", () => {
|
||||||
|
const zip = zipSync({
|
||||||
|
"SKILL.md": strToU8("# Zip Skill"),
|
||||||
|
"data.json": strToU8("{}"),
|
||||||
|
});
|
||||||
|
const files = parseUpload("bundle.zip", Buffer.from(zip));
|
||||||
|
expect(files.map((f) => f.path).sort()).toEqual(["SKILL.md", "data.json"].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to single-file handling when a .skill is not a valid archive", () => {
|
||||||
|
const buf = Buffer.from("# Just markdown, not a real archive", "utf-8");
|
||||||
|
const files = parseUpload("plain.skill", buf);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0].path).toBe("plain.skill");
|
||||||
|
expect(files[0].kind).toBe("resource");
|
||||||
|
expect(files[0].isBinary).toBe(false);
|
||||||
|
expect(files[0].content).toBe("# Just markdown, not a real archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a plain non-archive file as a single file", () => {
|
||||||
|
const buf = Buffer.from("print('hi')", "utf-8");
|
||||||
|
const files = parseUpload("main.py", buf);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0].path).toBe("main.py");
|
||||||
|
expect(files[0].kind).toBe("script");
|
||||||
|
expect(files[0].language).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates limit errors for real archives (signature present)", () => {
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
for (let i = 0; i < 2001; i++) {
|
||||||
|
entries[`f${i}.txt`] = strToU8("x");
|
||||||
|
}
|
||||||
|
const zip = zipSync(entries);
|
||||||
|
expect(() => parseUpload("big.skill", Buffer.from(zip))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to single-file when an empty archive yields no files", () => {
|
||||||
|
const zip = zipSync({ "emptydir/": strToU8("") });
|
||||||
|
const files = parseUpload("empty.skill", Buffer.from(zip));
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0].path).toBe("empty.skill");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("deriveScanName", () => {
|
describe("deriveScanName", () => {
|
||||||
it("uses the H1 heading from SKILL.md", () => {
|
it("uses the H1 heading from SKILL.md", () => {
|
||||||
const files = [
|
const files = [
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,21 @@ const MAX_ZIP_FILES = 2000;
|
||||||
const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024;
|
const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024;
|
||||||
const MAX_ZIP_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_ZIP_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the local-file-header signature ("PK\x03\x04") that every real ZIP
|
||||||
|
* archive begins with. `.skill` files exported by the Skill tooling are ZIP
|
||||||
|
* containers, so we sniff the bytes rather than trusting the file extension.
|
||||||
|
*/
|
||||||
|
function looksLikeZip(buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= 4 &&
|
||||||
|
buffer[0] === 0x50 &&
|
||||||
|
buffer[1] === 0x4b &&
|
||||||
|
buffer[2] === 0x03 &&
|
||||||
|
buffer[3] === 0x04
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function extOf(path: string): string {
|
function extOf(path: string): string {
|
||||||
const base = path.split("/").pop() ?? path;
|
const base = path.split("/").pop() ?? path;
|
||||||
const dot = base.lastIndexOf(".");
|
const dot = base.lastIndexOf(".");
|
||||||
|
|
@ -196,6 +211,33 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for uploaded files (single-file *and* ZIP-area uploads). A
|
||||||
|
* `.skill` file is really a ZIP container, so we treat any upload that either
|
||||||
|
* carries the ZIP signature or uses a `.zip`/`.skill` extension as an archive
|
||||||
|
* and extract it via the streaming ZIP path. If the buffer is not a real
|
||||||
|
* archive (e.g. someone named a plain text file `.skill`) we fall back cleanly
|
||||||
|
* to single-file handling instead of failing. Real archives still surface their
|
||||||
|
* limit/corruption errors so the existing protections stay in force.
|
||||||
|
*/
|
||||||
|
export function parseUpload(filename: string, buffer: Buffer): ParsedFile[] {
|
||||||
|
const isZipSignature = looksLikeZip(buffer);
|
||||||
|
const hasArchiveExt = /\.(zip|skill)$/i.test(filename);
|
||||||
|
if (isZipSignature || hasArchiveExt) {
|
||||||
|
try {
|
||||||
|
const files = parseZip(buffer);
|
||||||
|
if (files.length > 0) return files;
|
||||||
|
// A valid-but-empty archive falls through to single-file handling.
|
||||||
|
} catch (err) {
|
||||||
|
// A buffer with a real ZIP signature is genuinely an archive, so limit
|
||||||
|
// and corruption errors must surface. An extension-only guess that is
|
||||||
|
// not actually a ZIP falls back to single-file handling.
|
||||||
|
if (isZipSignature) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [parseSingleFile(filename, buffer)];
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
|
export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
|
||||||
const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename;
|
const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename;
|
||||||
const hash = hashBytes(buffer);
|
const hash = hashBytes(buffer);
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ import {
|
||||||
GetScanLineageResponse,
|
GetScanLineageResponse,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
import {
|
import {
|
||||||
parseZip,
|
parseUpload,
|
||||||
parseSingleFile,
|
|
||||||
parseText,
|
parseText,
|
||||||
deriveScanName,
|
deriveScanName,
|
||||||
} from "../lib/skillParser";
|
} from "../lib/skillParser";
|
||||||
|
|
@ -301,16 +300,17 @@ function parseScanInput(input: CreateScanInput): ParseResult {
|
||||||
if (input.source === "zip") {
|
if (input.source === "zip") {
|
||||||
if (!input.contentBase64)
|
if (!input.contentBase64)
|
||||||
return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." };
|
return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." };
|
||||||
files = parseZip(Buffer.from(input.contentBase64, "base64"));
|
files = parseUpload(
|
||||||
|
input.filename ?? "archiv.zip",
|
||||||
|
Buffer.from(input.contentBase64, "base64"),
|
||||||
|
);
|
||||||
} else if (input.source === "file") {
|
} else if (input.source === "file") {
|
||||||
if (!input.contentBase64)
|
if (!input.contentBase64)
|
||||||
return { ok: false, status: 400, message: "Dateiinhalt fehlt." };
|
return { ok: false, status: 400, message: "Dateiinhalt fehlt." };
|
||||||
files = [
|
files = parseUpload(
|
||||||
parseSingleFile(
|
input.filename ?? "datei",
|
||||||
input.filename ?? "datei",
|
Buffer.from(input.contentBase64, "base64"),
|
||||||
Buffer.from(input.contentBase64, "base64"),
|
);
|
||||||
),
|
|
||||||
];
|
|
||||||
} else {
|
} else {
|
||||||
if (!input.text || !input.text.trim())
|
if (!input.text || !input.text.trim())
|
||||||
return { ok: false, status: 400, message: "Text fehlt." };
|
return { ok: false, status: 400, message: "Text fehlt." };
|
||||||
|
|
|
||||||
|
|
@ -369,9 +369,9 @@ export default function ScanForm() {
|
||||||
<Input id="file-single" type="file" onChange={handleFileChange} />
|
<Input id="file-single" type="file" onChange={handleFileChange} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="zip" className="m-0 space-y-2">
|
<TabsContent value="zip" className="m-0 space-y-2">
|
||||||
<Label htmlFor="file-zip">Skill-Verzeichnis (.zip)</Label>
|
<Label htmlFor="file-zip">Skill-Verzeichnis (.zip oder .skill von Coworker)</Label>
|
||||||
<Input id="file-zip" type="file" accept=".zip" onChange={handleFileChange} />
|
<Input id="file-zip" type="file" accept=".zip,.skill" onChange={handleFileChange} />
|
||||||
<p className="text-xs text-muted-foreground mt-2">Das Archiv sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.</p>
|
<p className="text-xs text-muted-foreground mt-2">Das Archiv (.zip oder eine als .skill exportierte Datei) sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.</p>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="text" className="m-0 space-y-2">
|
<TabsContent value="text" className="m-0 space-y-2">
|
||||||
<Label htmlFor="raw-text">Skill Instructions</Label>
|
<Label htmlFor="raw-text">Skill Instructions</Label>
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,143 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2 } from "lucide-react";
|
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check } from "lucide-react";
|
||||||
import type { ScanDetail } from "@workspace/api-client-react";
|
import type { ScanDetail } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
type ScanReportFile = ScanDetail["files"][number];
|
||||||
|
|
||||||
|
type FileTreeNode =
|
||||||
|
| { type: "file"; name: string; file: ScanReportFile }
|
||||||
|
| { type: "dir"; name: string; children: FileTreeNode[] };
|
||||||
|
|
||||||
|
function buildFileTree(files: ScanReportFile[]): FileTreeNode[] {
|
||||||
|
const root: FileTreeNode = { type: "dir", name: "", children: [] };
|
||||||
|
for (const file of files) {
|
||||||
|
const parts = file.path.split("/").filter(Boolean);
|
||||||
|
let cursor = root as Extract<FileTreeNode, { type: "dir" }>;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const seg = parts[i];
|
||||||
|
let next = cursor.children.find(
|
||||||
|
(c): c is Extract<FileTreeNode, { type: "dir" }> => c.type === "dir" && c.name === seg,
|
||||||
|
);
|
||||||
|
if (!next) {
|
||||||
|
next = { type: "dir", name: seg, children: [] };
|
||||||
|
cursor.children.push(next);
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
cursor.children.push({ type: "file", name: parts[parts.length - 1] ?? file.path, file });
|
||||||
|
}
|
||||||
|
sortFileTree(root.children);
|
||||||
|
return root.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFileTree(nodes: FileTreeNode[]) {
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "dir") sortFileTree(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatFileRow = { depth: number; node: FileTreeNode };
|
||||||
|
|
||||||
|
function flattenFileTree(nodes: FileTreeNode[], depth = 0, out: FlatFileRow[] = []): FlatFileRow[] {
|
||||||
|
for (const node of nodes) {
|
||||||
|
out.push({ depth, node });
|
||||||
|
if (node.type === "dir") flattenFileTree(node.children, depth + 1, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesTree({ files }: { files: ScanReportFile[] }) {
|
||||||
|
const rows = useMemo(() => flattenFileTree(buildFileTree(files)), [files]);
|
||||||
|
const [copiedHash, setCopiedHash] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const copyHash = async (hash: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(hash);
|
||||||
|
setCopiedHash(hash);
|
||||||
|
window.setTimeout(() => setCopiedHash((cur) => (cur === hash ? null : cur)), 1500);
|
||||||
|
} catch {
|
||||||
|
// Clipboard unavailable (e.g. non-secure context) — silently ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-muted-foreground">
|
||||||
|
<th className="h-10 px-4 text-left font-medium">Pfad</th>
|
||||||
|
<th className="h-10 px-4 text-left font-medium">Typ</th>
|
||||||
|
<th className="h-10 px-4 text-left font-medium">Sprache</th>
|
||||||
|
<th className="h-10 px-4 text-left font-medium">Hash (SHA-256)</th>
|
||||||
|
<th className="h-10 px-4 text-right font-medium">Größe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map(({ depth, node }, i) =>
|
||||||
|
node.type === "dir" ? (
|
||||||
|
<tr key={i} className="border-b last:border-0 bg-muted/20">
|
||||||
|
<td colSpan={5} className="p-2 px-4">
|
||||||
|
<span className="inline-flex items-center gap-2 font-medium" style={{ paddingLeft: `${depth * 1.25}rem` }}>
|
||||||
|
<Folder className="w-4 h-4 text-amber-500 shrink-0" />
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||||
|
<td className="p-4 font-mono">
|
||||||
|
<span className="inline-flex items-center gap-2" style={{ paddingLeft: `${depth * 1.25}rem` }}>
|
||||||
|
<FileIcon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{node.file.kind === "instruction" ? "Anweisung" : node.file.kind === "script" ? "Skript" : "Ressource"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-muted-foreground capitalize">{node.file.language || "-"}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||||
|
<span title={node.file.hash}>{node.file.hash ? node.file.hash.slice(0, 12) : "-"}</span>
|
||||||
|
{node.file.hash && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyHash(node.file.hash)}
|
||||||
|
title="Vollständigen SHA-256 kopieren"
|
||||||
|
aria-label="Vollständigen SHA-256 kopieren"
|
||||||
|
className="inline-flex items-center justify-center rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{copiedHash === node.file.hash ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!node.file.hasContent && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">binär</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right font-mono">{node.file.size} B</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScanReport() {
|
export default function ScanReport() {
|
||||||
const [, params] = useRoute("/berichte/:id");
|
const [, params] = useRoute("/berichte/:id");
|
||||||
const id = Number(params?.id);
|
const id = Number(params?.id);
|
||||||
|
|
@ -497,48 +631,10 @@ export default function ScanReport() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Geprüfte Dateien</CardTitle>
|
<CardTitle>Geprüfte Dateien</CardTitle>
|
||||||
<CardDescription>Liste aller vom Scanner verarbeiteten Dateien</CardDescription>
|
<CardDescription>Ordnerstruktur aller vom Scanner verarbeiteten Dateien. Klicken Sie auf das Kopier-Symbol für den vollständigen SHA-256.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<FilesTree files={data.files} />
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/50 text-muted-foreground">
|
|
||||||
<th className="h-10 px-4 text-left font-medium">Pfad</th>
|
|
||||||
<th className="h-10 px-4 text-left font-medium">Typ</th>
|
|
||||||
<th className="h-10 px-4 text-left font-medium">Sprache</th>
|
|
||||||
<th className="h-10 px-4 text-left font-medium">Hash (SHA-256)</th>
|
|
||||||
<th className="h-10 px-4 text-right font-medium">Größe</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.files.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
data.files.map((file, i) => (
|
|
||||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
|
||||||
<td className="p-4 font-mono">{file.path}</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{file.kind === "instruction" ? "Anweisung" : file.kind === "script" ? "Skript" : "Ressource"}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-muted-foreground capitalize">{file.language || "-"}</td>
|
|
||||||
<td className="p-4 font-mono text-xs text-muted-foreground" title={file.hash}>
|
|
||||||
{file.hash ? file.hash.slice(0, 12) : "-"}
|
|
||||||
{!file.hasContent && (
|
|
||||||
<Badge variant="outline" className="ml-2 text-[10px]">binär</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-right font-mono">{file.size} B</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue