analyze performance upgrade
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user