Add live progress updates and detailed scan checkpoints to scan results
Introduce streaming endpoint for NDJSON scan progress, incorporate scan checkpoints into scan details, and update UI components to display this new information. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 0d01f99a-ea6a-447d-82fd-311715434a39 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 2852b526-3bf8-4a93-a62a-a50e26291074 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/e32d2b99-1721-47dd-833c-98b372f48008/0d01f99a-ea6a-447d-82fd-311715434a39/8MCgDZm Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
87d71c1dca
commit
434ec07885
21 changed files with 1064 additions and 110 deletions
|
|
@ -1,2 +1,3 @@
|
|||
- [lucide-react icon name collisions](lucide-icon-name-collisions.md) — `Badge`/`Activity` from lucide collide with shadcn/ui Badge and React 19 Activity; import Badge from ui, Activity from lucide.
|
||||
- [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails.
|
||||
- [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows.
|
||||
|
|
|
|||
36
.agents/memory/ndjson-streaming-express-replit.md
Normal file
36
.agents/memory/ndjson-streaming-express-replit.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: NDJSON streaming under Express behind the Replit proxy
|
||||
description: Pitfalls for live/streaming HTTP responses (NDJSON/SSE) from an Express API on Replit — client-disconnect detection, persistence, and proxy buffering
|
||||
---
|
||||
|
||||
# Streaming responses (NDJSON) from Express on Replit
|
||||
|
||||
For a long-lived POST that streams progress (e.g. `application/x-ndjson` with
|
||||
`res.flushHeaders()` then line-delimited JSON writes):
|
||||
|
||||
**Detect a real client abort with `res.on("close")` + `res.writableFinished` — never
|
||||
`req.on("close")`.**
|
||||
`req.on("close")` fires as soon as the POST *request body* has been fully consumed,
|
||||
which is normal and happens immediately. Gating writes on that wrongly suppresses the
|
||||
entire stream. Use the *response* close event, and treat it as an abort only when
|
||||
`!res.writableFinished`.
|
||||
|
||||
**Persist the result regardless of disconnect.** Run the analysis and the DB write
|
||||
inside the handler's `try` unconditionally; only guard the `res.write()` calls on
|
||||
"connection still open". This way a client that disconnects mid-stream still gets its
|
||||
result persisted (and the client must NOT re-submit on a mid-stream error, or it
|
||||
creates a duplicate row — see below).
|
||||
|
||||
**The Replit path-proxy does NOT buffer the stream.** Incremental flushes arrive line
|
||||
by line through `$REPLIT_DEV_DOMAIN`, same as `localhost`. No special proxy header or
|
||||
chunk-padding needed.
|
||||
|
||||
**Client fetch must be root-relative (`/api/...`), not `import.meta.env.BASE_URL`.**
|
||||
That matches the generated API client and is what routes correctly through the proxy.
|
||||
|
||||
**Fallback gating to avoid duplicate persists:** a client that streams and also falls
|
||||
back to a non-streaming POST on error must only fall back when the server never
|
||||
processed the request (fetch rejected, or `!res.ok` with a 5xx). Once the response
|
||||
body has started streaming, any read error means the server is already persisting —
|
||||
falling back then duplicates the record. Distinguish 4xx (show message, no retry)
|
||||
from transport failures.
|
||||
|
|
@ -16,6 +16,5 @@ rather than hardcoding a low value for determinism.
|
|||
**Why:** older models accepted `temperature: 0.1` but gpt-5* only accept the default.
|
||||
A hardcoded low temperature breaks every scan against modern models.
|
||||
|
||||
**How to apply:** in `artifacts/api-server/src/lib/aiAnalysis.ts` the OpenAI-compatible
|
||||
path no longer sends `temperature`. If reintroducing sampling controls, gate them per
|
||||
model or make them optional.
|
||||
**How to apply:** in the OpenAI-compatible AI-analysis path, do not send `temperature`.
|
||||
If reintroducing sampling controls, gate them per model or make them optional.
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ import {
|
|||
type Severity,
|
||||
type Axis,
|
||||
} from "./ruleCatalog";
|
||||
import type { FindingCounts as DbFindingCounts } from "@workspace/db";
|
||||
import type {
|
||||
FindingCounts as DbFindingCounts,
|
||||
ScanCheckpoint,
|
||||
} from "@workspace/db";
|
||||
import { runAiAnalysis, type AiRuleConfig } from "./aiAnalysis";
|
||||
|
||||
export type { ScanCheckpoint } from "@workspace/db";
|
||||
|
||||
const SEVERITY_WEIGHT: Record<Severity, number> = {
|
||||
critical: 50,
|
||||
high: 18,
|
||||
|
|
@ -26,9 +31,18 @@ const SEVERITY_WEIGHT: Record<Severity, number> = {
|
|||
info: 0,
|
||||
};
|
||||
|
||||
export type ScanProgressEvent =
|
||||
| { type: "ai-start" }
|
||||
| { type: "checkpoint"; checkpoint: ScanCheckpoint };
|
||||
|
||||
export type ProgressFn = (
|
||||
event: ScanProgressEvent,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type EngineResult = {
|
||||
findings: RawFinding[];
|
||||
counts: DbFindingCounts;
|
||||
checkpoints: ScanCheckpoint[];
|
||||
riskScore: number;
|
||||
verdict: "pass" | "review" | "block";
|
||||
aiUsed: boolean;
|
||||
|
|
@ -70,29 +84,75 @@ export function computeVerdict(
|
|||
return "pass";
|
||||
}
|
||||
|
||||
function scoreOf(findings: RawFinding[]): number {
|
||||
return findings.reduce((s, f) => s + SEVERITY_WEIGHT[f.severity], 0);
|
||||
}
|
||||
|
||||
export async function analyzeSkill(
|
||||
files: ParsedFile[],
|
||||
useAi: boolean,
|
||||
onProgress?: ProgressFn,
|
||||
): Promise<EngineResult> {
|
||||
const dbRules = await db.select().from(rulesTable);
|
||||
const ruleConfig = new Map(
|
||||
dbRules.map((r) => [r.ruleId, { enabled: r.enabled, severity: r.severity as Severity }]),
|
||||
dbRules.map((r) => [
|
||||
r.ruleId,
|
||||
{ enabled: r.enabled, severity: r.severity as Severity },
|
||||
]),
|
||||
);
|
||||
|
||||
const findings: RawFinding[] = [];
|
||||
const checkpoints: ScanCheckpoint[] = [];
|
||||
|
||||
for (const rule of STATIC_RULES) {
|
||||
const cfg = ruleConfig.get(rule.ruleId);
|
||||
if (cfg && !cfg.enabled) continue;
|
||||
const severity = cfg?.severity ?? rule.defaultSeverity;
|
||||
for (const file of files) {
|
||||
findings.push(...runStaticRule(rule, file, severity));
|
||||
|
||||
if (cfg && !cfg.enabled) {
|
||||
const checkpoint: ScanCheckpoint = {
|
||||
id: rule.ruleId,
|
||||
label: rule.title,
|
||||
category: rule.category,
|
||||
axis: rule.axis,
|
||||
severity,
|
||||
status: "skipped",
|
||||
findingCount: 0,
|
||||
scoreDelta: 0,
|
||||
detectedBy: "static",
|
||||
};
|
||||
checkpoints.push(checkpoint);
|
||||
await onProgress?.({ type: "checkpoint", checkpoint });
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleFindings: RawFinding[] = [];
|
||||
for (const file of files) {
|
||||
ruleFindings.push(...runStaticRule(rule, file, severity));
|
||||
}
|
||||
findings.push(...ruleFindings);
|
||||
|
||||
const checkpoint: ScanCheckpoint = {
|
||||
id: rule.ruleId,
|
||||
label: rule.title,
|
||||
category: rule.category,
|
||||
axis: rule.axis,
|
||||
severity,
|
||||
status: ruleFindings.length > 0 ? "flagged" : "pass",
|
||||
findingCount: ruleFindings.length,
|
||||
scoreDelta: scoreOf(ruleFindings),
|
||||
detectedBy: "static",
|
||||
};
|
||||
checkpoints.push(checkpoint);
|
||||
await onProgress?.({ type: "checkpoint", checkpoint });
|
||||
}
|
||||
|
||||
let aiUsed = false;
|
||||
let aiError: string | null = null;
|
||||
let aiFindings: RawFinding[] = [];
|
||||
|
||||
if (useAi) {
|
||||
await onProgress?.({ type: "ai-start" });
|
||||
|
||||
const aiRuleIds = new Set(AI_RULES.map((r) => r.ruleId));
|
||||
const enabledAiRules: AiRuleConfig[] = AI_RULES.filter((rule) => {
|
||||
const cfg = ruleConfig.get(rule.ruleId);
|
||||
|
|
@ -131,14 +191,58 @@ export async function analyzeSkill(
|
|||
aiError = result.error;
|
||||
if (!result.error) {
|
||||
aiUsed = true;
|
||||
aiFindings = result.findings;
|
||||
findings.push(...result.findings);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of AI_RULES) {
|
||||
const cfg = ruleConfig.get(rule.ruleId);
|
||||
const severity = cfg?.severity ?? rule.defaultSeverity;
|
||||
const enabled = cfg ? cfg.enabled : true;
|
||||
|
||||
let status: ScanCheckpoint["status"];
|
||||
let findingCount = 0;
|
||||
let scoreDelta = 0;
|
||||
|
||||
if (!enabled) {
|
||||
status = "skipped";
|
||||
} else if (!aiUsed) {
|
||||
status = "error";
|
||||
} else {
|
||||
const ruleFindings = aiFindings.filter((f) => f.ruleId === rule.ruleId);
|
||||
findingCount = ruleFindings.length;
|
||||
scoreDelta = scoreOf(ruleFindings);
|
||||
status = findingCount > 0 ? "flagged" : "pass";
|
||||
}
|
||||
|
||||
const checkpoint: ScanCheckpoint = {
|
||||
id: rule.ruleId,
|
||||
label: rule.title,
|
||||
category: rule.category,
|
||||
axis: rule.axis,
|
||||
severity,
|
||||
status,
|
||||
findingCount,
|
||||
scoreDelta,
|
||||
detectedBy: "ai",
|
||||
};
|
||||
checkpoints.push(checkpoint);
|
||||
await onProgress?.({ type: "checkpoint", checkpoint });
|
||||
}
|
||||
}
|
||||
|
||||
const riskScore = computeScore(findings);
|
||||
const counts = computeCounts(findings);
|
||||
const verdict = computeVerdict(findings, riskScore);
|
||||
|
||||
return { findings, counts, riskScore, verdict, aiUsed, aiError };
|
||||
return {
|
||||
findings,
|
||||
counts,
|
||||
checkpoints,
|
||||
riskScore,
|
||||
verdict,
|
||||
aiUsed,
|
||||
aiError,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ import {
|
|||
parseText,
|
||||
deriveScanName,
|
||||
} from "../lib/skillParser";
|
||||
import { analyzeSkill } from "../lib/scanEngine";
|
||||
import type { ParsedFile } from "../lib/ruleCatalog";
|
||||
import { analyzeSkill, type EngineResult } from "../lib/scanEngine";
|
||||
import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
type CreateScanInput = ReturnType<typeof CreateScanBody.parse>;
|
||||
|
||||
export function serializeScan(scan: Scan) {
|
||||
return {
|
||||
id: scan.id,
|
||||
|
|
@ -69,32 +71,33 @@ function serializeFinding(f: Finding) {
|
|||
};
|
||||
}
|
||||
|
||||
router.get("/scans", async (_req, res) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.orderBy(desc(scansTable.createdAt));
|
||||
res.json(ListScansResponse.parse(rows.map(serializeScan)));
|
||||
});
|
||||
function serializeScanDetail(
|
||||
scan: Scan,
|
||||
files: ScanFile[],
|
||||
findings: Finding[],
|
||||
) {
|
||||
return {
|
||||
...serializeScan(scan),
|
||||
checkpoints: scan.checkpoints ?? [],
|
||||
files: files.map(serializeFile),
|
||||
findings: [...findings].sort((a, b) => a.id - b.id).map(serializeFinding),
|
||||
};
|
||||
}
|
||||
|
||||
router.post("/scans", async (req, res) => {
|
||||
const parsed = CreateScanBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
||||
}
|
||||
const input = parsed.data;
|
||||
type ParseResult =
|
||||
| { ok: true; files: ParsedFile[] }
|
||||
| { ok: false; status: number; message: string };
|
||||
|
||||
let files: ParsedFile[] = [];
|
||||
function parseScanInput(input: CreateScanInput): ParseResult {
|
||||
try {
|
||||
let files: ParsedFile[];
|
||||
if (input.source === "zip") {
|
||||
if (!input.contentBase64)
|
||||
return res.status(400).json({ message: "ZIP-Inhalt fehlt." });
|
||||
return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." };
|
||||
files = parseZip(Buffer.from(input.contentBase64, "base64"));
|
||||
} else if (input.source === "file") {
|
||||
if (!input.contentBase64)
|
||||
return res.status(400).json({ message: "Dateiinhalt fehlt." });
|
||||
return { ok: false, status: 400, message: "Dateiinhalt fehlt." };
|
||||
files = [
|
||||
parseSingleFile(
|
||||
input.filename ?? "datei",
|
||||
|
|
@ -103,26 +106,33 @@ router.post("/scans", async (req, res) => {
|
|||
];
|
||||
} else {
|
||||
if (!input.text || !input.text.trim())
|
||||
return res.status(400).json({ message: "Text fehlt." });
|
||||
return { ok: false, status: 400, message: "Text fehlt." };
|
||||
files = [parseText(input.text, input.name ?? "SKILL.md")];
|
||||
}
|
||||
if (files.length === 0)
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: "Keine analysierbaren Dateien gefunden.",
|
||||
};
|
||||
return { ok: true, files };
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Skill-Parsing fehlgeschlagen");
|
||||
return res.status(400).json({
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message:
|
||||
"Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).",
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Keine analysierbaren Dateien gefunden." });
|
||||
}
|
||||
|
||||
const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill");
|
||||
const result = await analyzeSkill(files, input.useAi);
|
||||
|
||||
async function persistScan(
|
||||
input: CreateScanInput,
|
||||
name: string,
|
||||
files: ParsedFile[],
|
||||
result: EngineResult,
|
||||
): Promise<{ scan: Scan; files: ScanFile[]; findings: Finding[] }> {
|
||||
const [scan] = await db
|
||||
.insert(scansTable)
|
||||
.values({
|
||||
|
|
@ -135,6 +145,7 @@ router.post("/scans", async (req, res) => {
|
|||
aiUsed: result.aiUsed,
|
||||
aiError: result.aiError,
|
||||
findingCounts: result.counts,
|
||||
checkpoints: result.checkpoints,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -176,14 +187,127 @@ router.post("/scans", async (req, res) => {
|
|||
.returning();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...serializeScan(scan),
|
||||
files: insertedFiles.map(serializeFile),
|
||||
findings: insertedFindings
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map(serializeFinding),
|
||||
return { scan, files: insertedFiles, findings: insertedFindings };
|
||||
}
|
||||
|
||||
router.get("/scans", async (_req, res) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.orderBy(desc(scansTable.createdAt));
|
||||
res.json(ListScansResponse.parse(rows.map(serializeScan)));
|
||||
});
|
||||
|
||||
router.post("/scans", async (req, res) => {
|
||||
const parsed = CreateScanBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
const parseResult = parseScanInput(input);
|
||||
if (!parseResult.ok) {
|
||||
return res.status(parseResult.status).json({ message: parseResult.message });
|
||||
}
|
||||
const files = parseResult.files;
|
||||
|
||||
const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill");
|
||||
const result = await analyzeSkill(files, input.useAi);
|
||||
const { scan, files: insertedFiles, findings } = await persistScan(
|
||||
input,
|
||||
name,
|
||||
files,
|
||||
result,
|
||||
);
|
||||
|
||||
return res
|
||||
.status(201)
|
||||
.json(GetScanResponse.parse(serializeScanDetail(scan, insertedFiles, findings)));
|
||||
});
|
||||
|
||||
const STREAM_PACING_MS = 80;
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
router.post("/scans/stream", async (req, res) => {
|
||||
const parsed = CreateScanBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
const parseResult = parseScanInput(input);
|
||||
if (!parseResult.ok) {
|
||||
res.status(parseResult.status).json({ message: parseResult.message });
|
||||
return;
|
||||
}
|
||||
const files = parseResult.files;
|
||||
const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill");
|
||||
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.flushHeaders();
|
||||
|
||||
// Detect a genuine client disconnect. NOTE: do NOT use req.on("close") here —
|
||||
// for a POST it fires as soon as the request body is consumed, not on abort.
|
||||
// res "close" before writableFinished means the client went away.
|
||||
let aborted = false;
|
||||
res.on("close", () => {
|
||||
if (!res.writableFinished) aborted = true;
|
||||
});
|
||||
|
||||
const write = (obj: unknown) => {
|
||||
if (aborted || res.writableEnded) return;
|
||||
res.write(JSON.stringify(obj) + "\n");
|
||||
};
|
||||
return res.status(201).json(GetScanResponse.parse(payload));
|
||||
|
||||
write({
|
||||
type: "start",
|
||||
name,
|
||||
fileCount: files.length,
|
||||
totalChecks: STATIC_RULES.length + (input.useAi ? AI_RULES.length : 0),
|
||||
});
|
||||
|
||||
let cumulative = 0;
|
||||
try {
|
||||
const result = await analyzeSkill(files, input.useAi, async (event) => {
|
||||
if (event.type === "ai-start") {
|
||||
write({ type: "ai-start" });
|
||||
return;
|
||||
}
|
||||
cumulative += event.checkpoint.scoreDelta;
|
||||
write({
|
||||
type: "checkpoint",
|
||||
checkpoint: event.checkpoint,
|
||||
runningScore: Math.min(100, cumulative),
|
||||
});
|
||||
if (!aborted) await delay(STREAM_PACING_MS);
|
||||
});
|
||||
|
||||
const { scan } = await persistScan(input, name, files, result);
|
||||
|
||||
write({
|
||||
type: "done",
|
||||
scanId: scan.id,
|
||||
riskScore: result.riskScore,
|
||||
verdict: result.verdict,
|
||||
findingCounts: result.counts,
|
||||
aiUsed: result.aiUsed,
|
||||
aiError: result.aiError,
|
||||
});
|
||||
if (!aborted && !res.writableEnded) res.end();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Streaming-Scan fehlgeschlagen");
|
||||
write({ type: "error", message: "Die Analyse ist fehlgeschlagen." });
|
||||
if (!aborted && !res.writableEnded) res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/scans/:id", async (req, res) => {
|
||||
|
|
@ -207,12 +331,7 @@ router.get("/scans/:id", async (req, res) => {
|
|||
.where(eq(findingsTable.scanId, scan.id))
|
||||
.orderBy(findingsTable.id);
|
||||
|
||||
const payload = {
|
||||
...serializeScan(scan),
|
||||
files: files.map(serializeFile),
|
||||
findings: findings.map(serializeFinding),
|
||||
};
|
||||
return res.json(GetScanResponse.parse(payload));
|
||||
return res.json(GetScanResponse.parse(serializeScanDetail(scan, files, findings)));
|
||||
});
|
||||
|
||||
router.delete("/scans/:id", async (req, res) => {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 80 KiB |
|
|
@ -1,5 +1,27 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon } from "lucide-react";
|
||||
import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon, CheckCircle2, MinusCircle, XCircle } from "lucide-react";
|
||||
|
||||
export const CHECKPOINT_STATUS_LABELS: Record<string, string> = {
|
||||
pass: "Unauffällig",
|
||||
flagged: "Auffällig",
|
||||
skipped: "Übersprungen",
|
||||
error: "Fehler",
|
||||
};
|
||||
|
||||
export function CheckpointStatusBadge({ status, className }: { status: string, className?: string }) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return <Badge className={`bg-emerald-500 hover:bg-emerald-600 text-white border-transparent ${className}`}><CheckCircle2 className="w-3 h-3 mr-1"/> Unauffällig</Badge>;
|
||||
case "flagged":
|
||||
return <Badge className={`bg-rose-500 hover:bg-rose-600 text-white border-transparent ${className}`}><AlertTriangle className="w-3 h-3 mr-1"/> Auffällig</Badge>;
|
||||
case "skipped":
|
||||
return <Badge variant="outline" className={`text-muted-foreground ${className}`}><MinusCircle className="w-3 h-3 mr-1"/> Übersprungen</Badge>;
|
||||
case "error":
|
||||
return <Badge className={`bg-amber-500 hover:bg-amber-600 text-white border-transparent ${className}`}><XCircle className="w-3 h-3 mr-1"/> Fehler</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" className={className}>{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
export function VerdictBadge({ verdict, className }: { verdict: string, className?: string }) {
|
||||
switch (verdict) {
|
||||
|
|
|
|||
119
artifacts/skillguard/src/lib/streamScan.ts
Normal file
119
artifacts/skillguard/src/lib/streamScan.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type {
|
||||
SkillScanInput,
|
||||
ScanCheckpoint,
|
||||
FindingCounts,
|
||||
} from "@workspace/api-client-react";
|
||||
|
||||
export type ScanStreamEvent =
|
||||
| { type: "start"; name: string; fileCount: number; totalChecks: number }
|
||||
| { type: "ai-start" }
|
||||
| { type: "checkpoint"; checkpoint: ScanCheckpoint; runningScore: number }
|
||||
| {
|
||||
type: "done";
|
||||
scanId: number;
|
||||
riskScore: number;
|
||||
verdict: string;
|
||||
findingCounts: FindingCounts;
|
||||
aiUsed: boolean;
|
||||
aiError: string | null;
|
||||
}
|
||||
| { type: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Fehler beim Aufruf des Streaming-Endpunkts. `allowFallback` zeigt an, ob ein
|
||||
* Rückfall auf den nicht-streamenden Endpunkt sinnvoll ist. Bei Client-Fehlern
|
||||
* (4xx, z. B. ungültige Eingabe) ist das nicht der Fall – die Servermeldung
|
||||
* soll direkt angezeigt werden, statt dieselbe Anfrage erneut zu senden.
|
||||
*/
|
||||
export class ScanStreamError extends Error {
|
||||
allowFallback: boolean;
|
||||
constructor(message: string, allowFallback: boolean) {
|
||||
super(message);
|
||||
this.name = "ScanStreamError";
|
||||
this.allowFallback = allowFallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamt einen Skill-Scan über NDJSON. Ruft `onEvent` für jedes Ereignis auf,
|
||||
* sobald es eintrifft (Live-Prüfschritte). Wirft bei Transportfehlern; ein vom
|
||||
* Server gemeldeter Analysefehler kommt als `{ type: "error" }`-Ereignis an.
|
||||
*
|
||||
* Nutzt – wie der generierte API-Client – eine wurzelrelative `/api`-URL, damit
|
||||
* der Replit-Pfad-Proxy die Anfrage korrekt an den API-Server weiterleitet.
|
||||
*/
|
||||
export async function streamScan(
|
||||
input: SkillScanInput,
|
||||
onEvent: (event: ScanStreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/scans/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// Verbindung kam nicht zustande – der Server hat die Anfrage nicht erhalten.
|
||||
// Rückfall auf den nicht-streamenden Endpunkt ist sicher und sinnvoll.
|
||||
throw new ScanStreamError(
|
||||
err instanceof Error ? err.message : "Verbindung zum Server fehlgeschlagen.",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
let message = "Live-Analyse nicht verfügbar.";
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.message === "string") message = body.message;
|
||||
} catch {
|
||||
// ignore – Standardmeldung verwenden
|
||||
}
|
||||
// 4xx = Client-/Eingabefehler: Meldung direkt anzeigen, kein erneuter Versuch.
|
||||
// 5xx oder fehlender Body: Rückfall auf den nicht-streamenden Endpunkt erlauben.
|
||||
const isClientError = res.status >= 400 && res.status < 500;
|
||||
throw new ScanStreamError(message, !isClientError);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const emit = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
onEvent(JSON.parse(trimmed) as ScanStreamEvent);
|
||||
} catch {
|
||||
// Unvollständige / nicht-JSON Zeile ignorieren
|
||||
}
|
||||
};
|
||||
|
||||
// Ab hier verarbeitet der Server die Anfrage und persistiert das Ergebnis auch
|
||||
// bei Verbindungsabbruch. Ein Fehler beim Lesen darf daher NICHT zum Rückfall
|
||||
// führen, sonst entsteht ein doppelter Scan-Eintrag.
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
emit(line);
|
||||
}
|
||||
}
|
||||
emit(buffer);
|
||||
} catch (err) {
|
||||
throw new ScanStreamError(
|
||||
err instanceof Error && err.name === "AbortError"
|
||||
? "Die Analyse wurde abgebrochen."
|
||||
: "Die Live-Übertragung wurde unterbrochen. Der Scan läuft möglicherweise im Hintergrund weiter – bitte prüfen Sie den Verlauf.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useCreateScan, SkillScanInputSource } from "@workspace/api-client-react";
|
||||
import {
|
||||
useCreateScan,
|
||||
SkillScanInputSource,
|
||||
type SkillScanInput,
|
||||
type ScanCheckpoint,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -8,26 +13,126 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { FileUp, FileText, Type, Loader2, ShieldCheck } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckpointStatusBadge, AxisBadge } from "@/components/ui-helpers";
|
||||
import {
|
||||
FileUp,
|
||||
FileText,
|
||||
Type,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { streamScan, ScanStreamError, type ScanStreamEvent } from "@/lib/streamScan";
|
||||
|
||||
type Phase = "idle" | "scanning" | "done";
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score < 30) return "text-emerald-500";
|
||||
if (score < 70) return "text-amber-500";
|
||||
return "text-rose-500";
|
||||
}
|
||||
|
||||
function deltaLabel(checkpoint: ScanCheckpoint): string {
|
||||
if (checkpoint.status === "skipped") return "übersprungen";
|
||||
if (checkpoint.scoreDelta > 0) return `+${checkpoint.scoreDelta} Punkte`;
|
||||
return "0 Punkte";
|
||||
}
|
||||
|
||||
export default function ScanForm() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const createScan = useCreateScan();
|
||||
|
||||
|
||||
const [sourceType, setSourceType] = useState<SkillScanInputSource>("file");
|
||||
const [name, setName] = useState("");
|
||||
const [useAi, setUseAi] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [steps, setSteps] = useState<ScanCheckpoint[]>([]);
|
||||
const [runningScore, setRunningScore] = useState(0);
|
||||
const [totalChecks, setTotalChecks] = useState(0);
|
||||
const [aiActive, setAiActive] = useState(false);
|
||||
const [finalVerdict, setFinalVerdict] = useState<string | null>(null);
|
||||
|
||||
const isBusy = phase === "scanning" || phase === "done" || createScan.isPending;
|
||||
|
||||
const groupedSteps = useMemo(() => {
|
||||
const order: string[] = [];
|
||||
const groups = new Map<string, ScanCheckpoint[]>();
|
||||
for (const step of steps) {
|
||||
if (!groups.has(step.category)) {
|
||||
groups.set(step.category, []);
|
||||
order.push(step.category);
|
||||
}
|
||||
groups.get(step.category)!.push(step);
|
||||
}
|
||||
return order.map((category) => ({ category, steps: groups.get(category)! }));
|
||||
}, [steps]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const buildInput = async (): Promise<SkillScanInput> => {
|
||||
let contentBase64: string | undefined = undefined;
|
||||
let filename: string | undefined = undefined;
|
||||
|
||||
if (file && (sourceType === "file" || sourceType === "zip")) {
|
||||
const reader = new FileReader();
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1] || result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
contentBase64 = await base64Promise;
|
||||
filename = file.name;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name || undefined,
|
||||
source: sourceType,
|
||||
useAi,
|
||||
contentBase64,
|
||||
filename,
|
||||
text: sourceType === "text" ? text : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const finishWithScan = (scanId: number) => {
|
||||
setPhase("done");
|
||||
window.setTimeout(() => {
|
||||
toast({ title: "Scan abgeschlossen", description: "Der Bericht wird geöffnet." });
|
||||
setLocation(`/berichte/${scanId}`);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const runNonStreaming = async (input: SkillScanInput) => {
|
||||
try {
|
||||
const data = await createScan.mutateAsync({ data: input });
|
||||
finishWithScan(data.id);
|
||||
} catch (err) {
|
||||
setPhase("idle");
|
||||
toast({
|
||||
title: "Fehler",
|
||||
description: "Der Scan konnte nicht durchgeführt werden.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -40,49 +145,193 @@ export default function ScanForm() {
|
|||
return;
|
||||
}
|
||||
|
||||
let input: SkillScanInput;
|
||||
try {
|
||||
let contentBase64: string | undefined = undefined;
|
||||
let filename: string | undefined = undefined;
|
||||
input = await buildInput();
|
||||
} catch {
|
||||
toast({ title: "Fehler", description: "Beim Verarbeiten der Datei ist ein Fehler aufgetreten.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file && (sourceType === "file" || sourceType === "zip")) {
|
||||
const reader = new FileReader();
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Strip data URL prefix
|
||||
const base64 = result.split(',')[1] || result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
contentBase64 = await base64Promise;
|
||||
filename = file.name;
|
||||
}
|
||||
setPhase("scanning");
|
||||
setSteps([]);
|
||||
setRunningScore(0);
|
||||
setTotalChecks(0);
|
||||
setAiActive(false);
|
||||
setFinalVerdict(null);
|
||||
|
||||
createScan.mutate({
|
||||
data: {
|
||||
name: name || undefined,
|
||||
source: sourceType,
|
||||
useAi,
|
||||
contentBase64,
|
||||
filename,
|
||||
text: sourceType === "text" ? text : undefined
|
||||
}
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
toast({ title: "Scan erfolgreich", description: "Der Skill wurde erfolgreich analysiert." });
|
||||
setLocation(`/berichte/${data.id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Fehler", description: "Der Scan konnte nicht durchgeführt werden.", variant: "destructive" });
|
||||
let outcome: "done" | "error" | null = null;
|
||||
let doneScanId: number | null = null;
|
||||
let errorMessage = "Die Analyse ist fehlgeschlagen.";
|
||||
|
||||
try {
|
||||
await streamScan(input, (event: ScanStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
setTotalChecks(event.totalChecks);
|
||||
break;
|
||||
case "ai-start":
|
||||
setAiActive(true);
|
||||
break;
|
||||
case "checkpoint":
|
||||
setSteps((prev) => [...prev, event.checkpoint]);
|
||||
setRunningScore(event.runningScore);
|
||||
if (event.checkpoint.detectedBy === "ai") setAiActive(false);
|
||||
break;
|
||||
case "done":
|
||||
outcome = "done";
|
||||
doneScanId = event.scanId;
|
||||
setRunningScore(event.riskScore);
|
||||
setFinalVerdict(event.verdict);
|
||||
break;
|
||||
case "error":
|
||||
outcome = "error";
|
||||
errorMessage = event.message;
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ title: "Fehler", description: "Beim Verarbeiten der Datei ist ein Fehler aufgetreten.", variant: "destructive" });
|
||||
// Rückfall nur, wenn der Server die Anfrage nicht verarbeitet hat
|
||||
// (Verbindung kam nicht zustande oder 5xx). Bei Client-Fehlern (4xx) oder
|
||||
// einem Abbruch während des Streamings persistiert der Server bereits –
|
||||
// ein erneuter Versuch würde einen doppelten Eintrag erzeugen.
|
||||
const canFallback = err instanceof ScanStreamError ? err.allowFallback : true;
|
||||
if (canFallback) {
|
||||
await runNonStreaming(input);
|
||||
return;
|
||||
}
|
||||
setPhase("idle");
|
||||
toast({
|
||||
title: "Fehler",
|
||||
description: err instanceof Error ? err.message : "Die Analyse ist fehlgeschlagen.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome === "done" && doneScanId != null) {
|
||||
finishWithScan(doneScanId);
|
||||
} else if (outcome === "error") {
|
||||
setPhase("idle");
|
||||
toast({ title: "Fehler", description: errorMessage, variant: "destructive" });
|
||||
} else {
|
||||
// Stream endete ohne Abschluss-Ereignis: Fallback auf klassischen Scan.
|
||||
await runNonStreaming(input);
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "scanning" || phase === "done") {
|
||||
const completed = steps.length;
|
||||
const progressPct = totalChecks > 0 ? (completed / totalChecks) * 100 : 0;
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{phase === "done" ? "Analyse abgeschlossen" : "Analyse läuft"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{phase === "done"
|
||||
? "Alle Prüfschritte wurden ausgewertet. Der Bericht wird geöffnet."
|
||||
: "Verfolgen Sie jeden Prüfschritt und seine Teilbewertung in Echtzeit."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{phase === "done" ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
) : (
|
||||
<Activity className="w-5 h-5 text-primary animate-pulse" />
|
||||
)}
|
||||
Live-Risiko
|
||||
</CardTitle>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-4xl font-bold tabular-nums ${scoreColor(runningScore)}`}>
|
||||
{runningScore}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Prüfschritte</span>
|
||||
<span className="tabular-nums">
|
||||
{completed}{totalChecks > 0 ? ` / ${totalChecks}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2" />
|
||||
</div>
|
||||
|
||||
{aiActive && (
|
||||
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/30 border border-purple-100 dark:border-purple-900 rounded-md px-3 py-2">
|
||||
<Sparkles className="w-4 h-4 animate-pulse" />
|
||||
KI-Analyse läuft – semantische Prüfung der Instruktionen...
|
||||
</div>
|
||||
)}
|
||||
{finalVerdict && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Vorläufiges Ergebnis:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{finalVerdict === "pass"
|
||||
? "Freigabe"
|
||||
: finalVerdict === "review"
|
||||
? "Manuelle Prüfung"
|
||||
: "Blockieren"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{groupedSteps.map((group) => (
|
||||
<Card key={group.category}>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{group.category}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
{group.steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CheckpointStatusBadge status={step.status} />
|
||||
<span className="text-sm font-medium">{step.label}</span>
|
||||
{step.axis && <AxisBadge axis={step.axis} />}
|
||||
<Badge variant="secondary" className="text-xs bg-slate-200 dark:bg-slate-800">
|
||||
{step.detectedBy === "ai" ? "KI" : "Statisch"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-mono tabular-nums ${
|
||||
step.scoreDelta > 0 ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{deltaLabel(step)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{groupedSteps.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-12">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Initialisiere Prüfung...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
@ -99,9 +348,9 @@ export default function ScanForm() {
|
|||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Bezeichnung (optional)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="z.B. GitHub PR Reviewer Skill"
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="z.B. GitHub PR Reviewer Skill"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
|
@ -113,7 +362,7 @@ export default function ScanForm() {
|
|||
<TabsTrigger value="zip"><FileUp className="w-4 h-4 mr-2" /> ZIP-Archiv</TabsTrigger>
|
||||
<TabsTrigger value="text"><Type className="w-4 h-4 mr-2" /> Text</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<div className="mt-4 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/50">
|
||||
<TabsContent value="file" className="m-0 space-y-2">
|
||||
<Label htmlFor="file-single">Instruction-Datei (z.B. SKILL.md oder prompt.txt)</Label>
|
||||
|
|
@ -126,9 +375,9 @@ export default function ScanForm() {
|
|||
</TabsContent>
|
||||
<TabsContent value="text" className="m-0 space-y-2">
|
||||
<Label htmlFor="raw-text">Skill Instructions</Label>
|
||||
<Textarea
|
||||
id="raw-text"
|
||||
placeholder="Fügen Sie hier die Prompt-Instruktionen ein..."
|
||||
<Textarea
|
||||
id="raw-text"
|
||||
placeholder="Fügen Sie hier die Prompt-Instruktionen ein..."
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
|
|
@ -154,12 +403,8 @@ export default function ScanForm() {
|
|||
setText("");
|
||||
setUseAi(false);
|
||||
}}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={createScan.isPending}>
|
||||
{createScan.isPending ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Analysiere...</>
|
||||
) : (
|
||||
<><ShieldCheck className="w-4 h-4 mr-2" /> Scan starten</>
|
||||
)}
|
||||
<Button type="submit" disabled={isBusy}>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" /> Scan starten
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { VerdictBadge, SeverityBadge, AxisBadge } from "@/components/ui-helpers";
|
||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown } from "lucide-react";
|
||||
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks } from "lucide-react";
|
||||
import type { ScanDetail } from "@workspace/api-client-react";
|
||||
|
||||
export default function ScanReport() {
|
||||
|
|
@ -210,6 +210,9 @@ export default function ScanReport() {
|
|||
<Tabs defaultValue="findings" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="findings" className="gap-2"><Shield className="w-4 h-4"/> Auffälligkeiten ({data.findings.length})</TabsTrigger>
|
||||
{data.checkpoints && data.checkpoints.length > 0 && (
|
||||
<TabsTrigger value="checkpoints" className="gap-2"><ListChecks className="w-4 h-4"/> Prüfschritte ({data.checkpoints.length})</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="files" className="gap-2"><FileCode className="w-4 h-4"/> Geprüfte Dateien ({data.files.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -311,6 +314,49 @@ export default function ScanReport() {
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
{data.checkpoints && data.checkpoints.length > 0 && (
|
||||
<TabsContent value="checkpoints">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prüfschritte</CardTitle>
|
||||
<CardDescription>
|
||||
Jeder durchgeführte Prüfschritt mit seiner Teilbewertung. Die Teilbewertung zeigt den Beitrag zum Gesamt-Risiko-Score.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<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">Prüfschritt</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Kategorie</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Bereich</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Erkennung</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Status</th>
|
||||
<th className="h-10 px-4 text-right font-medium">Teilbewertung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.checkpoints.map((cp) => (
|
||||
<tr key={cp.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="p-4 font-medium">{cp.label}</td>
|
||||
<td className="p-4 text-muted-foreground">{cp.category}</td>
|
||||
<td className="p-4">{cp.axis ? <AxisBadge axis={cp.axis} /> : <span className="text-muted-foreground">-</span>}</td>
|
||||
<td className="p-4 text-muted-foreground">{cp.detectedBy === "ai" ? "KI" : "Statisch"}</td>
|
||||
<td className="p-4"><CheckpointStatusBadge status={cp.status} /></td>
|
||||
<td className={`p-4 text-right font-mono tabular-nums ${cp.scoreDelta > 0 ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground"}`}>
|
||||
{cp.status === "skipped" ? "übersprungen" : cp.scoreDelta > 0 ? `+${cp.scoreDelta}` : "0"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="files">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -445,6 +491,28 @@ function buildReportHtml(data: ScanDetail): string {
|
|||
? `<div class="warning">KI-Analyse nicht durchgeführt: ${escapeHtml(data.aiError)}. Die statische Analyse wurde dennoch abgeschlossen.</div>`
|
||||
: "";
|
||||
|
||||
const checkpointsSection = data.checkpoints && data.checkpoints.length > 0
|
||||
? `
|
||||
<h2>Prüfschritte (${data.checkpoints.length})</h2>
|
||||
<p class="subtitle">Jeder durchgeführte Prüfschritt mit seiner Teilbewertung (Beitrag zum Risiko-Score).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Prüfschritt</th><th>Kategorie</th><th>Bereich</th><th>Erkennung</th><th>Status</th><th>Teilbewertung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.checkpoints.map((cp) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(cp.label)}</td>
|
||||
<td>${escapeHtml(cp.category)}</td>
|
||||
<td>${cp.axis ? escapeHtml(AXIS_LABELS[cp.axis] ?? cp.axis) : "-"}</td>
|
||||
<td>${cp.detectedBy === "ai" ? "KI" : "Statisch"}</td>
|
||||
<td>${escapeHtml(CHECKPOINT_STATUS_LABELS[cp.status] ?? cp.status)}</td>
|
||||
<td class="num">${cp.status === "skipped" ? "übersprungen" : cp.scoreDelta > 0 ? `+${escapeHtml(cp.scoreDelta)}` : "0"}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
|
|
@ -523,6 +591,8 @@ function buildReportHtml(data: ScanDetail): string {
|
|||
<h2>Auffälligkeiten (${data.findings.length})</h2>
|
||||
${findingsHtml}
|
||||
|
||||
${checkpointsSection}
|
||||
|
||||
<h2>Geprüfte Dateien (${data.files.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -100,6 +100,70 @@ export interface Scan {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointAxis = typeof ScanCheckpointAxis[keyof typeof ScanCheckpointAxis] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointAxis = {
|
||||
security: 'security',
|
||||
privacy: 'privacy',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointSeverity = typeof ScanCheckpointSeverity[keyof typeof ScanCheckpointSeverity] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointSeverity = {
|
||||
critical: 'critical',
|
||||
high: 'high',
|
||||
medium: 'medium',
|
||||
low: 'low',
|
||||
info: 'info',
|
||||
} as const;
|
||||
|
||||
export type ScanCheckpointStatus = typeof ScanCheckpointStatus[keyof typeof ScanCheckpointStatus];
|
||||
|
||||
|
||||
export const ScanCheckpointStatus = {
|
||||
pass: 'pass',
|
||||
flagged: 'flagged',
|
||||
skipped: 'skipped',
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointDetectedBy = typeof ScanCheckpointDetectedBy[keyof typeof ScanCheckpointDetectedBy] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointDetectedBy = {
|
||||
static: 'static',
|
||||
ai: 'ai',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).
|
||||
*/
|
||||
export interface ScanCheckpoint {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
/** @nullable */
|
||||
axis?: ScanCheckpointAxis;
|
||||
/** @nullable */
|
||||
severity?: ScanCheckpointSeverity;
|
||||
status: ScanCheckpointStatus;
|
||||
findingCount: number;
|
||||
scoreDelta: number;
|
||||
/** @nullable */
|
||||
detectedBy?: ScanCheckpointDetectedBy;
|
||||
}
|
||||
|
||||
export type ScanFileKind = typeof ScanFileKind[keyof typeof ScanFileKind];
|
||||
|
||||
|
||||
|
|
@ -165,6 +229,7 @@ export interface Finding {
|
|||
export type ScanDetail = Scan & {
|
||||
files: ScanFile[];
|
||||
findings: Finding[];
|
||||
checkpoints: ScanCheckpoint[];
|
||||
};
|
||||
|
||||
export type AiProviderApiType = typeof AiProviderApiType[keyof typeof AiProviderApiType];
|
||||
|
|
|
|||
|
|
@ -399,6 +399,36 @@ components:
|
|||
total:
|
||||
type: integer
|
||||
|
||||
ScanCheckpoint:
|
||||
type: object
|
||||
description: >-
|
||||
A single inspection step (Prüfschritt) with its partial assessment
|
||||
(Teilbewertung).
|
||||
required: [id, label, category, status, findingCount, scoreDelta]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
axis:
|
||||
type: ["string", "null"]
|
||||
enum: [security, privacy, null]
|
||||
severity:
|
||||
type: ["string", "null"]
|
||||
enum: [critical, high, medium, low, info, null]
|
||||
status:
|
||||
type: string
|
||||
enum: [pass, flagged, skipped, error]
|
||||
findingCount:
|
||||
type: integer
|
||||
scoreDelta:
|
||||
type: integer
|
||||
detectedBy:
|
||||
type: ["string", "null"]
|
||||
enum: [static, ai, null]
|
||||
|
||||
ScanFile:
|
||||
type: object
|
||||
required: [path, kind, size]
|
||||
|
|
@ -454,7 +484,7 @@ components:
|
|||
allOf:
|
||||
- $ref: "#/components/schemas/Scan"
|
||||
- type: object
|
||||
required: [files, findings]
|
||||
required: [files, findings, checkpoints]
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
|
|
@ -464,6 +494,10 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Finding"
|
||||
checkpoints:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScanCheckpoint"
|
||||
|
||||
AiProvider:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -160,7 +160,18 @@ export const GetScanResponse = zod.object({
|
|||
"line": zod.number().nullish(),
|
||||
"snippet": zod.string().nullish(),
|
||||
"detectedBy": zod.enum(['static', 'ai'])
|
||||
}))
|
||||
})),
|
||||
"checkpoints": zod.array(zod.object({
|
||||
"id": zod.string(),
|
||||
"label": zod.string(),
|
||||
"category": zod.string(),
|
||||
"axis": zod.union([zod.literal('security'),zod.literal('privacy'),zod.literal(null)]).nullish(),
|
||||
"severity": zod.union([zod.literal('critical'),zod.literal('high'),zod.literal('medium'),zod.literal('low'),zod.literal('info'),zod.literal(null)]).nullish(),
|
||||
"status": zod.enum(['pass', 'flagged', 'skipped', 'error']),
|
||||
"findingCount": zod.number(),
|
||||
"scoreDelta": zod.number(),
|
||||
"detectedBy": zod.union([zod.literal('static'),zod.literal('ai'),zod.literal(null)]).nullish()
|
||||
}).describe('A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).'))
|
||||
}))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ export * from './ruleStatAxis';
|
|||
export * from './ruleUpdate';
|
||||
export * from './ruleUpdateSeverity';
|
||||
export * from './scan';
|
||||
export * from './scanCheckpoint';
|
||||
export * from './scanCheckpointAxis';
|
||||
export * from './scanCheckpointDetectedBy';
|
||||
export * from './scanCheckpointSeverity';
|
||||
export * from './scanCheckpointStatus';
|
||||
export * from './scanDetail';
|
||||
export * from './scanFile';
|
||||
export * from './scanFileKind';
|
||||
|
|
|
|||
29
lib/api-zod/src/generated/types/scanCheckpoint.ts
Normal file
29
lib/api-zod/src/generated/types/scanCheckpoint.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ScanCheckpointAxis } from './scanCheckpointAxis';
|
||||
import type { ScanCheckpointDetectedBy } from './scanCheckpointDetectedBy';
|
||||
import type { ScanCheckpointSeverity } from './scanCheckpointSeverity';
|
||||
import type { ScanCheckpointStatus } from './scanCheckpointStatus';
|
||||
|
||||
/**
|
||||
* A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).
|
||||
*/
|
||||
export interface ScanCheckpoint {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
/** @nullable */
|
||||
axis?: ScanCheckpointAxis;
|
||||
/** @nullable */
|
||||
severity?: ScanCheckpointSeverity;
|
||||
status: ScanCheckpointStatus;
|
||||
findingCount: number;
|
||||
scoreDelta: number;
|
||||
/** @nullable */
|
||||
detectedBy?: ScanCheckpointDetectedBy;
|
||||
}
|
||||
18
lib/api-zod/src/generated/types/scanCheckpointAxis.ts
Normal file
18
lib/api-zod/src/generated/types/scanCheckpointAxis.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointAxis = typeof ScanCheckpointAxis[keyof typeof ScanCheckpointAxis] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointAxis = {
|
||||
security: 'security',
|
||||
privacy: 'privacy',
|
||||
} as const;
|
||||
18
lib/api-zod/src/generated/types/scanCheckpointDetectedBy.ts
Normal file
18
lib/api-zod/src/generated/types/scanCheckpointDetectedBy.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointDetectedBy = typeof ScanCheckpointDetectedBy[keyof typeof ScanCheckpointDetectedBy] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointDetectedBy = {
|
||||
static: 'static',
|
||||
ai: 'ai',
|
||||
} as const;
|
||||
21
lib/api-zod/src/generated/types/scanCheckpointSeverity.ts
Normal file
21
lib/api-zod/src/generated/types/scanCheckpointSeverity.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanCheckpointSeverity = typeof ScanCheckpointSeverity[keyof typeof ScanCheckpointSeverity] | null;
|
||||
|
||||
|
||||
export const ScanCheckpointSeverity = {
|
||||
critical: 'critical',
|
||||
high: 'high',
|
||||
medium: 'medium',
|
||||
low: 'low',
|
||||
info: 'info',
|
||||
} as const;
|
||||
17
lib/api-zod/src/generated/types/scanCheckpointStatus.ts
Normal file
17
lib/api-zod/src/generated/types/scanCheckpointStatus.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ScanCheckpointStatus = typeof ScanCheckpointStatus[keyof typeof ScanCheckpointStatus];
|
||||
|
||||
|
||||
export const ScanCheckpointStatus = {
|
||||
pass: 'pass',
|
||||
flagged: 'flagged',
|
||||
skipped: 'skipped',
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
|
@ -7,9 +7,11 @@
|
|||
*/
|
||||
import type { Finding } from './finding';
|
||||
import type { Scan } from './scan';
|
||||
import type { ScanCheckpoint } from './scanCheckpoint';
|
||||
import type { ScanFile } from './scanFile';
|
||||
|
||||
export type ScanDetail = Scan & {
|
||||
files: ScanFile[];
|
||||
findings: Finding[];
|
||||
checkpoints: ScanCheckpoint[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
timestamp,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export type FindingCounts = {
|
||||
critical: number;
|
||||
|
|
@ -19,6 +20,20 @@ export type FindingCounts = {
|
|||
total: number;
|
||||
};
|
||||
|
||||
export type ScanCheckpointStatus = "pass" | "flagged" | "skipped" | "error";
|
||||
|
||||
export type ScanCheckpoint = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
axis: "security" | "privacy" | null;
|
||||
severity: "critical" | "high" | "medium" | "low" | "info" | null;
|
||||
status: ScanCheckpointStatus;
|
||||
findingCount: number;
|
||||
scoreDelta: number;
|
||||
detectedBy: "static" | "ai" | null;
|
||||
};
|
||||
|
||||
export const scansTable = pgTable("scans", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
|
|
@ -30,6 +45,10 @@ export const scansTable = pgTable("scans", {
|
|||
aiUsed: boolean("ai_used").notNull().default(false),
|
||||
aiError: text("ai_error"),
|
||||
findingCounts: jsonb("finding_counts").$type<FindingCounts>().notNull(),
|
||||
checkpoints: jsonb("checkpoints")
|
||||
.$type<ScanCheckpoint[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue