analyze performance upgrade

This commit is contained in:
DaStra3003
2026-09-23 08:01:49 +02:00
parent aabe35fead
commit 7f7b753350
51 changed files with 5827 additions and 1372 deletions
+173 -38
View File
@@ -7,6 +7,11 @@ import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRig
import { ProtectedRoute } from "@/components/protected-route"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { InventoryPanel } from "@/components/inventory-panel"
import { apiFetch, apiErrorMessage } from "@/lib/api"
import { getLogger } from "@/lib/logger"
const log = getLogger("findings")
// API types mirror the backend finding schema.
interface Finding {
@@ -36,12 +41,30 @@ interface Summary {
by_category: Record<string, number>
highlights: Finding[]
top: Finding[]
device_related?: number
other_hosts?: number
}
interface AnalysisResult {
meta?: Record<string, unknown>
summary: Summary
findings: Finding[]
// Complete observation inventory (hosts, endpoints, TLS, DNS, coverage).
inventory?: Record<string, unknown>
// Present while the capture analysis has not produced a report yet.
status?: "analyzing" | "failed" | "empty" | "ready"
capture_status?: string
message?: string
// Failure reason recorded by the backend and whether a per-capture log exists.
error?: string | null
log_available?: boolean
}
interface AnalysisLog {
available: boolean
log: string
path?: string
message?: string
}
interface DeepStatus {
@@ -122,6 +145,7 @@ function affectedLine(f: Finding): string {
// Evidence rendered in dedicated sections is excluded from raw details.
const EVIDENCE_SKIP = new Set([
"dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences",
"device_related",
])
type SourceRef = {
@@ -172,47 +196,57 @@ function FindingsContent() {
const [status, setStatus] = useState<DeepStatus | null>(null)
const [cloud, setCloud] = useState<CloudResults | null>(null)
const [analyzing, setAnalyzing] = useState(false)
const [captureRunning, setCaptureRunning] = useState(false)
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
const [backendLog, setBackendLog] = useState<AnalysisLog | null>(null)
const [logLoading, setLogLoading] = useState(false)
const fetchCloud = async () => {
if (!fileId) return
try {
const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) setCloud(await res.json())
} catch { /* Optional enrichment data does not block the finding view. */ }
setCloud(await apiFetch<CloudResults>(`get_cloud_results/${fileId}`))
} catch (e) {
log.debug(`Cloud results unavailable: ${apiErrorMessage(e)}`)
}
}
const fetchBackendLog = async () => {
if (!fileId) return
setLogLoading(true)
try {
setBackendLog(await apiFetch<AnalysisLog>(`get_analysis_log/${fileId}?lines=400`))
} catch (e) {
setBackendLog({ available: false, log: "", message: apiErrorMessage(e) })
} finally {
setLogLoading(false)
}
}
useEffect(() => {
if (!fileId) { setError("No capture ID was provided."); setLoading(false); return }
const load = async () => {
try {
const res = await fetch(getApiUrl(`get_findings/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setData(await res.json())
const result = await apiFetch<AnalysisResult>(`get_findings/${fileId}`)
setData(result)
setCaptureRunning(result.status === "analyzing")
log.info(`Capture ${fileId}: status=${result.status} findings=${result.findings?.length ?? 0}`,
result.error ? { error: result.error } : undefined)
if (result.status === "failed") fetchBackendLog()
} catch (e) {
setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`)
log.error(`Findings for capture ${fileId} could not be loaded: ${apiErrorMessage(e)}`)
setError(`Findings could not be loaded: ${apiErrorMessage(e)}`)
} finally {
setLoading(false)
}
await fetchCloud()
try {
const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (sres.ok) {
const s: DeepStatus = await sres.json()
setStatus(s)
if (s.state === "running") setAnalyzing(true)
}
} catch { /* Status is optional until enrichment starts. */ }
const s = await apiFetch<DeepStatus>(`get_analysis_status/${fileId}`)
setStatus(s)
if (s.state === "running") setAnalyzing(true)
} catch (e) {
log.debug(`Enrichment status unavailable: ${apiErrorMessage(e)}`)
}
}
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -237,16 +271,15 @@ function FindingsContent() {
let timer: ReturnType<typeof setInterval> | null = null
const poll = async () => {
try {
const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) {
const s: DeepStatus = await res.json()
setStatus(s)
if (s.state === "done" || s.state === "error") setAnalyzing(false)
const s = await apiFetch<DeepStatus>(`get_analysis_status/${fileId}`)
setStatus(s)
if (s.state === "done" || s.state === "error") {
setAnalyzing(false)
log.info(`Enrichment for capture ${fileId} finished: ${s.state} (${s.message})`)
}
} catch { /* Retry on the next polling interval. */ }
} catch (e) {
log.warn(`Enrichment status poll failed: ${apiErrorMessage(e)}`)
}
await fetchCloud()
}
poll()
@@ -255,20 +288,42 @@ function FindingsContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId, analyzing])
// Poll while the capture analysis itself is still running.
useEffect(() => {
if (!fileId || !captureRunning) return
let timer: ReturnType<typeof setInterval> | null = null
const poll = async () => {
try {
const result = await apiFetch<AnalysisResult>(`get_findings/${fileId}`)
setData(result)
if (result.status !== "analyzing") {
setCaptureRunning(false)
log.info(`Capture ${fileId} analysis finished: ${result.status}`, result.error ? { error: result.error } : undefined)
if (result.status === "failed") fetchBackendLog()
await fetchCloud()
}
} catch (e) {
log.warn(`Capture status poll failed: ${apiErrorMessage(e)}`)
}
}
timer = setInterval(poll, 5000)
return () => { if (timer) clearInterval(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId, captureRunning])
const startAnalysis = async () => {
if (!fileId || (selUrls.size + selHashes.size) === 0) return
try {
const response = await fetch(getApiUrl(`start_analysis/${fileId}`), {
await apiFetch(`start_analysis/${fileId}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }),
cache: "no-store",
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
log.info(`Enrichment started for capture ${fileId}: ${selUrls.size} URLs, ${selHashes.size} hashes`)
setAnalyzing(true)
} catch (e) {
setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`)
log.error(`Enrichment could not be started: ${apiErrorMessage(e)}`)
setError(`Enrichment could not be started: ${apiErrorMessage(e)}`)
}
}
@@ -333,6 +388,65 @@ function FindingsContent() {
</CardDescription>
</CardHeader>
<CardContent>
{/* Status of the capture analysis itself. */}
{data?.status && data.status !== "ready" && (
<div className={`mb-6 rounded-lg border p-4 ${
data.status === "analyzing" ? "border-blue-300 bg-blue-50"
: data.status === "failed" ? "border-red-300 bg-red-50"
: "border-gray-300 bg-gray-50"
}`}>
<p className="font-medium">
{data.status === "analyzing" && "⏳ The capture is being analyzed…"}
{data.status === "failed" && "⚠️ The capture analysis failed"}
{data.status === "empty" && "No security analysis is available for this capture."}
</p>
<p className="text-sm text-muted-foreground mt-1">
{data.status === "analyzing"
? "Large captures take a moment. This page refreshes automatically."
: data.message}
</p>
{data.status === "failed" && (
<div className="mt-3 space-y-2">
{data.error && (
<div className="rounded-md border border-red-200 bg-white/70 p-2">
<div className="text-xs font-medium text-red-800">Error</div>
<code className="text-xs break-all whitespace-pre-wrap">{data.error}</code>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={fetchBackendLog} disabled={logLoading}>
{logLoading ? "Loading log…" : backendLog ? "Refresh analysis log" : "Show analysis log"}
</Button>
{backendLog?.available && backendLog.path && (
<span className="text-xs text-muted-foreground">Backend file: {backendLog.path}</span>
)}
</div>
{backendLog && (
backendLog.available ? (
<pre className="max-h-96 overflow-auto rounded-md bg-slate-900 p-3 text-[11px] leading-snug text-slate-100 whitespace-pre-wrap break-all">{backendLog.log || "(empty log)"}</pre>
) : (
<p className="text-xs text-muted-foreground">{backendLog.message || "No analysis log is available."}</p>
)
)}
<p className="text-xs text-muted-foreground">
Container log: <code>docker compose logs backend</code> · rotating file: <code>/app/logs/backend.log</code> ·
client log: <code>window.__iotpDumpLogs()</code> in the browser console.
</p>
</div>
)}
</div>
)}
{/* A partial pass must be visible as such. */}
{Boolean(data?.meta?.truncated) && (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
<p className="font-medium">⚠️ Incomplete analysis</p>
<p className="text-sm text-muted-foreground mt-1">
{String(data?.meta?.truncation_reason ?? "Configured analysis limits were reached.")}
</p>
</div>
)}
{/* Status of optional URL and hash enrichment. */}
{status && status.state !== "pending" && (
<div className={`mb-6 rounded-lg border p-4 ${
@@ -380,6 +494,20 @@ function FindingsContent() {
</div>
)}
{/* Device under test */}
{Boolean(data?.meta?.device_ip) && (
<div className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30 p-4 text-sm">
<span className="font-medium">Device under test:</span>{" "}
<code>{String(data?.meta?.device_ip)}</code>
{Array.isArray(data?.meta?.device_macs) && (data?.meta?.device_macs as string[]).length > 0 && (
<> (MAC {(data?.meta?.device_macs as string[]).join(", ")})</>
)}
<span className="text-muted-foreground">
{" "}· device-related findings: {summary?.device_related ?? 0} · other hosts: {summary?.other_hosts ?? 0}
</span>
</div>
)}
{/* Prioritized highlights */}
{summary && summary.highlights?.length > 0 && (
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
@@ -413,7 +541,12 @@ function FindingsContent() {
<Card key={f.finding_id} className="border-l-4" style={{ borderLeftColor: sevBorder(f.severity) }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold">{i + 1}. {f.title}</h4>
<h4 className="font-semibold">
{i + 1}. {f.title}
{f.evidence?.device_related === true && (
<span className="ml-2 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white align-middle">DEVICE</span>
)}
</h4>
<div className="flex items-center gap-2">
<Pill severity={f.severity} />
<span className="text-xs text-muted-foreground">Confidence: {f.confidence}</span>
@@ -470,6 +603,8 @@ function FindingsContent() {
</div>
)}
{data?.status === "ready" && <InventoryPanel inventory={data?.inventory} />}
{cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (
<h2 className="text-2xl font-bold mt-10 mb-3">Extracted artifacts</h2>
)}