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
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
import { getLogger, describeError } from "@/lib/logger"
const log = getLogger("route")
export default function RouteError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
log.error(`Render failure on ${typeof window !== "undefined" ? window.location.pathname : "?"}: ${describeError(error)}`,
{ digest: error.digest, stack: error.stack })
}, [error])
return (
<div className="mx-auto max-w-2xl p-8">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="mt-2 text-sm text-muted-foreground">{describeError(error)}</p>
{error.digest && <p className="mt-1 text-xs text-muted-foreground">Digest: {error.digest}</p>}
<p className="mt-3 text-xs text-muted-foreground">
Details are in the browser console; run <code>window.__iotpDumpLogs()</code> to copy the client log.
</p>
<Button className="mt-4" onClick={() => reset()}>Try again</Button>
</div>
)
}
+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>
)}
+5 -1
View File
@@ -5,6 +5,9 @@ import * as React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import axios from "axios"
import { getLogger } from "@/lib/logger"
const log = getLogger("upload")
import { FileIcon, UploadCloudIcon, XIcon, ScanIcon, ArrowLeft } from "lucide-react"
import { useDropzone } from "react-dropzone"
import type { FileRejection } from "react-dropzone"
@@ -95,9 +98,10 @@ export function FileUpload({ onBack, deviceId }: FileUploadProps) {
})
const fileId = response.data.data.id
log.info(`Capture uploaded: file_id=${fileId} name=${file.name} size=${file.size} device=${deviceId} ip=${fileDetails.ipAddress}`)
router.push(`/scan-results?fileId=${fileId}`)
} catch (err) {
console.error("Scan error:", err)
log.error(`Upload of ${file.name} (${file.size} bytes) failed`, err)
const apiError = axios.isAxiosError(err)
? err.response?.data?.error || err.response?.data?.capture?.[0] || err.message
: err instanceof Error
+262
View File
@@ -0,0 +1,262 @@
"use client"
// Observation inventory: everything the analysis saw, so that an analyst can
// verify nothing was overlooked even when no finding was raised.
type Rec = Record<string, unknown>
interface Props {
inventory?: Rec | null
}
function kb(n: unknown): string {
const v = typeof n === "number" ? n : Number(n ?? 0)
if (!isFinite(v)) return "—"
if (v >= 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`
if (v >= 1024) return `${(v / 1024).toFixed(1)} kB`
return `${v} B`
}
function list(v: unknown, max = 8): string {
if (!Array.isArray(v) || v.length === 0) return "—"
const s = v.slice(0, max).map(String).join(", ")
return v.length > max ? `${s}, … (+${v.length - max})` : s
}
function Section({ title, count, open, children }: { title: string; count?: number; open?: boolean; children: React.ReactNode }) {
return (
<details className="mt-2 rounded-lg border bg-card" open={open}>
<summary className="cursor-pointer px-4 py-2 font-medium flex items-center justify-between">
<span>{title}</span>
{typeof count === "number" && <span className="text-xs text-muted-foreground">{count}</span>}
</summary>
<div className="px-4 pb-3 text-sm overflow-x-auto">{children}</div>
</details>
)
}
function DeviceBadge({ on }: { on: unknown }) {
return on ? <span className="ml-1 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white">DEVICE</span> : null
}
export function InventoryPanel({ inventory }: Props) {
if (!inventory || typeof inventory !== "object") return null
const cap = (inventory.capture as Rec) || {}
const dev = (inventory.device as Rec) || {}
const hosts = (inventory.hosts as Rec[]) || []
const eps = (inventory.external_endpoints as Rec[]) || []
const svcs = (inventory.services as Rec[]) || []
const tls = (inventory.tls as Rec) || {}
const dns = (inventory.dns as Rec) || {}
const http = (inventory.http as Rec) || {}
const clear = (inventory.cleartext as Rec) || {}
const cov = (inventory.coverage as Rec) || {}
const protos = ((inventory.protocols as Rec) || {}).by_protocol as Record<string, number> | undefined
return (
<div className="mt-10">
<h2 className="text-2xl font-bold mb-1">Observation inventory</h2>
<p className="text-sm text-muted-foreground mb-3">
Complete list of what the capture contained — {String(cap.packets ?? 0)} packets, {String(cap.internal_hosts ?? 0)} internal
and {String(cap.external_hosts ?? 0)} external hosts, {String(cap.conversations ?? 0)} flows
{Array.isArray(cap.vlans) && cap.vlans.length > 0 && <> · VLANs {list(cap.vlans)}</>}
{cap.extractor ? <> · {String(cap.extractor)}</> : null}.
</p>
{/* Coverage first: an analyst must know what could not be inspected. */}
<div className={`rounded-lg border p-3 text-sm ${cov.complete ? "border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30" : "border-amber-300 bg-amber-50 dark:bg-amber-950/30"}`}>
<div className="font-medium">{cov.complete ? "✅ Complete extraction" : "⚠️ Partial extraction"}</div>
<div className="text-muted-foreground">
{String(cov.flows_total ?? 0)} flows · {String(cov.flows_encrypted ?? 0)} encrypted · {String(cov.flows_undecoded ?? 0)} with undecoded payload ·
{" "}{String(cov.text_payloads_scanned ?? 0)} clear-text payloads scanned
</div>
{Array.isArray(cov.notes) && cov.notes.length > 0 && (
<ul className="mt-1 list-disc pl-5 text-muted-foreground">
{(cov.notes as string[]).map((n, i) => <li key={i}>{n}</li>)}
</ul>
)}
</div>
<Section title="Device under test" open>
{!dev.configured ? (
<div>
<p className="text-muted-foreground">No device address was configured for this capture. Likely candidates:</p>
<ul className="list-disc pl-5">
{((dev.candidates as Rec[]) || []).map((c, i) => (
<li key={i}><code>{String(c.ip)}</code> {list(c.names)} · {list(c.macs)} · {String(c.packets)} packets</li>
))}
</ul>
</div>
) : !dev.seen ? (
<p className="text-amber-700">{String(dev.note)}</p>
) : (
<div className="space-y-1">
<div><code>{String(dev.ip)}</code> · MAC {list(dev.macs)} · names {list(dev.names)}
{dev.dhcp_vendor_class ? <> · DHCP vendor class {String(dev.dhcp_vendor_class)}</> : null}</div>
<div className="text-muted-foreground">{String(dev.packets)} packets · sent {kb(dev.bytes_sent)} · received {kb(dev.bytes_received)}</div>
<div>Services offered: {list(dev.ports_offered)} · ports contacted: {list(dev.ports_contacted, 20)}</div>
<div>Protocols: {list(dev.protocols, 20)} · TLS handshakes: {String(dev.tls_sessions ?? 0)} · clear-text flows: {String(dev.cleartext_flows ?? 0)}</div>
{Array.isArray(dev.dns_queries) && dev.dns_queries.length > 0 && <div>DNS names queried: {list(dev.dns_queries, 30)}</div>}
{Array.isArray(dev.peers) && dev.peers.length > 0 && (
<table className="mt-2 w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>Peer</th><th>Names</th><th>Scope</th><th>Ports</th><th>Protocols</th><th>To peer</th><th>From peer</th></tr></thead>
<tbody>
{(dev.peers as Rec[]).map((p, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(p.ip)}</td><td>{list(p.names, 3)}</td>
<td>{p.external ? "external" : "internal"}</td><td>{list(p.ports, 6)}</td>
<td>{list(p.protocols, 5)}</td><td>{kb(p.bytes_to_peer)}</td><td>{kb(p.bytes_from_peer)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</Section>
<Section title="Internal hosts" count={hosts.length}>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>IP</th><th>Names</th><th>MAC</th><th>Packets</th><th>Offers</th><th>Ext. endpoints</th><th>Protocols</th></tr></thead>
<tbody>
{hosts.map((h, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(h.ip)}<DeviceBadge on={h.is_device} /></td><td>{list(h.names, 3)}</td>
<td className="font-mono">{list(h.macs, 2)}</td><td>{String(h.packets)}</td><td>{list(h.ports_offered, 8)}</td>
<td>{String(h.external_endpoints ?? 0)}</td><td>{list(h.protocols, 6)}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section title="External endpoints" count={eps.length}>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>IP</th><th>Names</th><th>Ports</th><th>Protocols</th><th>Encrypted</th><th>Out</th><th>In</th><th>Internal hosts</th></tr></thead>
<tbody>
{eps.map((e, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(e.ip)}<DeviceBadge on={e.device} /></td><td>{list(e.names, 3)}</td>
<td>{list(e.ports, 6)}</td><td>{list(e.protocols, 5)}</td>
<td>{e.encrypted ? "yes" : <span className="text-amber-700">no / unknown</span>}</td>
<td>{kb(e.bytes_out)}</td><td>{kb(e.bytes_in)}</td><td>{list(e.internal_hosts, 3)}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section title="Services observed" count={svcs.length}>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>Endpoint</th><th>Service</th><th>Transport</th><th>Protocols</th><th>Encryption</th><th>Flows</th><th>Clients</th></tr></thead>
<tbody>
{svcs.map((s, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(s.ip)}:{String(s.port)}<DeviceBadge on={s.device} /></td>
<td>{String(s.service)}</td><td>{String(s.transport)}</td><td>{list(s.protocols, 4)}</td>
<td>{s.encrypted ? "encrypted" : s.cleartext_app ? <span className="text-amber-700">clear-text</span> : "unknown"}</td>
<td>{String(s.flows)}</td><td>{list(s.clients, 3)}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section title="TLS posture" count={Number(tls.sessions_total ?? 0)}>
<div className="text-xs text-muted-foreground mb-2">
Versions: {Object.entries((tls.versions as Record<string, number>) || {}).map(([k, v]) => `${k} ×${v}`).join(", ") || "—"} ·
Ciphers: {Object.entries((tls.ciphers as Record<string, number>) || {}).map(([k, v]) => `${k} ×${v}`).join(", ") || "—"}
</div>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>Client → Server</th><th>SNI</th><th>Version</th><th>Cipher</th><th>Client max</th><th>Certificate</th><th>Notes</th></tr></thead>
<tbody>
{((tls.sessions as Rec[]) || []).map((s, i) => {
const cert = (s.certificate as Rec) || null
return (
<tr key={i} className="border-t">
<td className="font-mono">{String(s.client)} → {String(s.server)}:{String(s.port)}<DeviceBadge on={s.device} /></td>
<td>{s.sni ? String(s.sni) : "—"}</td><td>{s.version ? String(s.version) : "no ServerHello"}</td>
<td>{s.cipher ? String(s.cipher) : "—"}</td><td>{s.client_max_version ? String(s.client_max_version) : "—"}</td>
<td>{cert ? `${String(cert.subject ?? "?")} by ${String(cert.issuer ?? "?")}${cert.not_after ? `, until ${String(cert.not_after)}` : ""}` : (s.version === "TLS 1.3" ? "encrypted (TLS 1.3)" : "—")}</td>
<td>{[s.mutual_tls ? "mutual TLS" : null, Array.isArray(s.alerts) && s.alerts.length ? `alerts: ${list(s.alerts, 3)}` : null, Array.isArray(s.alpn) && s.alpn.length ? `ALPN ${list(s.alpn, 2)}` : null].filter(Boolean).join(" · ") || "—"}</td>
</tr>
)
})}
</tbody>
</table>
</Section>
<Section title="DNS" count={Number(dns.unique_names ?? 0)}>
<div className="text-xs text-muted-foreground mb-2">
{String(dns.queries ?? 0)} queries · {String(dns.responses ?? 0)} responses · codes {Object.entries((dns.rcodes as Record<string, number>) || {}).map(([k, v]) => `${k} ${v}`).join(", ") || "—"} ·
resolvers {Object.entries((dns.resolvers as Record<string, number>) || {}).map(([k, v]) => `${k} (${v})`).join(", ") || "—"}
</div>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>Name</th><th>Queries</th><th>Types</th><th>Answers</th><th>Clients</th></tr></thead>
<tbody>
{((dns.names as Rec[]) || []).map((n, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(n.name)}<DeviceBadge on={n.device} /></td><td>{String(n.queries)}</td>
<td>{list(n.types, 4)}</td><td className="font-mono">{list(n.answers, 3)}</td><td>{list(n.clients, 3)}</td>
</tr>
))}
</tbody>
</table>
</Section>
{(Number(http.requests ?? 0) > 0 || (Array.isArray(http.file_transfers) && http.file_transfers.length > 0)) && (
<Section title="HTTP" count={Number(http.requests ?? 0)}>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted-foreground"><th>Host</th><th>Requests</th><th>Methods</th><th>URIs</th><th>Clients</th><th>User agents</th></tr></thead>
<tbody>
{((http.hosts as Rec[]) || []).map((h, i) => (
<tr key={i} className="border-t">
<td className="font-mono">{String(h.host)}<DeviceBadge on={h.device} /></td><td>{String(h.requests)}</td>
<td>{list(h.methods, 4)}</td><td className="font-mono break-all">{list(h.uris, 5)}</td>
<td>{list(h.clients, 3)}</td><td className="break-all">{list(h.user_agents, 2)}</td>
</tr>
))}
</tbody>
</table>
{Array.isArray(http.file_transfers) && http.file_transfers.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{(http.file_transfers as Rec[]).map((f, i) => (
<li key={i}>File transfer: <code>{String(f.filename ?? f.uri ?? f.content_type)}</code> ({String(f.protocol)}, {String(f.direction)}, {String(f.src_ip)} → {String(f.dst_ip)}{f.size ? `, ${kb(f.size)}` : ""})</li>
))}
</ul>
)}
</Section>
)}
<Section title="Clear-text protocols, service events and DHCP">
{Array.isArray(clear.flows) && clear.flows.length > 0 ? (
<ul className="list-disc pl-5 text-xs">
{(clear.flows as Rec[]).map((f, i) => (
<li key={i}><span className="font-medium">{String(f.protocol)}</span><DeviceBadge on={f.device} /> · {String(f.flows)} flows, {kb(f.bytes)} · {list(f.pairs, 6)}</li>
))}
</ul>
) : <p className="text-xs text-muted-foreground">No clear-text application flows were observed.</p>}
{Array.isArray(clear.service_events) && clear.service_events.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{(clear.service_events as Rec[]).map((s, i) => (
<li key={i}>{String(s.protocol)} {String(s.kind)}: {String(s.value ?? "")} {String(s.detail ?? "")} ({String(s.src_ip)} → {String(s.dst_ip)})</li>
))}
</ul>
)}
{Array.isArray(clear.dhcp) && clear.dhcp.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{(clear.dhcp as Rec[]).map((d, i) => (
<li key={i}>DHCP {String(d.type)}: <span className="font-mono">{String(d.client_mac)}</span> {String(d.hostname ?? "")} {String(d.vendor_class ?? "")} → {String(d.client_ip ?? "")} (server {String(d.server ?? "—")})</li>
))}
</ul>
)}
</Section>
{protos && (
<Section title="Protocols (packets)">
<div className="text-xs">{Object.entries(protos).slice(0, 40).map(([k, v]) => `${k} ${v}`).join(" · ")}</div>
</Section>
)}
</div>
)
}
+5 -3
View File
@@ -1,8 +1,10 @@
'use client'
"use client"
import { AuthProvider } from '@/lib/auth-context'
import { useEffect } from "react"
import { AuthProvider } from "@/lib/auth-context"
import { installGlobalErrorLogging } from "@/lib/logger"
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => { installGlobalErrorLogging() }, [])
return <AuthProvider>{children}</AuthProvider>
}
+26
View File
@@ -1,10 +1,36 @@
import axios from "axios"
import { getLogger } from "@/lib/logger"
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"
// The Django API uses an authenticated session shared across localhost ports.
axios.defaults.withCredentials = true
// Log every axios call with duration, status and the backend request id.
const log = getLogger("axios")
axios.interceptors.request.use((config) => {
(config as typeof config & { metadata?: { started: number } }).metadata = { started: performance.now() }
return config
})
axios.interceptors.response.use(
(response) => {
const started = (response.config as typeof response.config & { metadata?: { started: number } }).metadata?.started
const ms = started ? Math.round(performance.now() - started) : -1
log.debug(`${(response.config.method || "get").toUpperCase()} ${response.config.url} -> ${response.status} in ${ms} ms`,
{ requestId: response.headers?.["x-request-id"] })
return response
},
(error) => {
const cfg = error?.config || {}
const status = error?.response?.status
const data = error?.response?.data
const message = (data && typeof data === "object" && (data.error || data.detail)) || error?.message
log.error(`${(cfg.method || "get").toUpperCase()} ${cfg.url} failed${status ? ` with ${status}` : ""}: ${message}`,
{ requestId: error?.response?.headers?.["x-request-id"], data })
return Promise.reject(error)
},
)
export const getApiUrl = (endpoint: string): string => {
const path = endpoint.replace(/^\/+/, "")
return `${API_BASE_URL.replace(/\/+$/, "")}/${path}`
+73
View File
@@ -0,0 +1,73 @@
// fetch wrapper for the Django API: logs every call and turns HTTP errors
// into ApiError with the server's message and request id.
import { getApiUrl } from "@/config/api"
import { getLogger, describeError } from "@/lib/logger"
const log = getLogger("api")
export class ApiError extends Error {
status: number
requestId: string | null
body: unknown
constructor(message: string, status: number, requestId: string | null, body: unknown) {
super(message)
this.name = "ApiError"
this.status = status
this.requestId = requestId
this.body = body
}
}
// Message shown to the user for a failed call.
export function apiErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
const rid = err.requestId ? ` (request ${err.requestId})` : ""
return `${err.message}${rid}`
}
if (err instanceof TypeError) {
return "The backend could not be reached. Is the API running and NEXT_PUBLIC_API_URL correct?"
}
return describeError(err)
}
async function parseBody(res: Response): Promise<unknown> {
const text = await res.text()
if (!text) return null
try { return JSON.parse(text) } catch { return text }
}
function messageFromBody(body: unknown, status: number): string {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>
for (const key of ["error", "detail", "message"]) {
if (typeof b[key] === "string") return b[key] as string
}
}
if (typeof body === "string" && body.length < 200) return body
return `HTTP ${status}`
}
export async function apiFetch<T = unknown>(endpoint: string, init: RequestInit = {}): Promise<T> {
const url = getApiUrl(endpoint)
const method = (init.method || "GET").toUpperCase()
const started = performance.now()
let res: Response
try {
res = await fetch(url, { cache: "no-store", credentials: "include", ...init })
} catch (err) {
log.error(`${method} ${endpoint} network failure after ${Math.round(performance.now() - started)} ms: ${describeError(err)}`)
throw err
}
const requestId = res.headers.get("X-Request-ID")
const body = await parseBody(res)
const ms = Math.round(performance.now() - started)
if (!res.ok) {
const message = messageFromBody(body, res.status)
log.warn(`${method} ${endpoint} -> ${res.status} in ${ms} ms: ${message}`, { requestId, body })
throw new ApiError(message, res.status, requestId, body)
}
log.debug(`${method} ${endpoint} -> ${res.status} in ${ms} ms`, { requestId })
return body as T
}
+92
View File
@@ -0,0 +1,92 @@
// Browser-side logger with levels, timestamps and a ring buffer.
//
// Level: NEXT_PUBLIC_LOG_LEVEL at build time, overridable per browser with
// localStorage.setItem("iotp_log_level", "debug"). The last 300 entries are
// kept in window.__iotpLogs and can be copied with window.__iotpDumpLogs().
export type LogLevel = "debug" | "info" | "warn" | "error"
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
const BUFFER_SIZE = 300
interface LogEntry {
ts: string
level: LogLevel
scope: string
message: string
data?: unknown
}
declare global {
interface Window {
__iotpLogs?: LogEntry[]
__iotpDumpLogs?: () => string
}
}
function configuredLevel(): LogLevel {
let level = (process.env.NEXT_PUBLIC_LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug")) as LogLevel
if (typeof window !== "undefined") {
try {
const override = window.localStorage.getItem("iotp_log_level") as LogLevel | null
if (override && override in ORDER) level = override
} catch { /* storage may be unavailable */ }
}
return level in ORDER ? level : "info"
}
function buffer(): LogEntry[] {
if (typeof window === "undefined") return []
if (!window.__iotpLogs) {
window.__iotpLogs = []
window.__iotpDumpLogs = () =>
(window.__iotpLogs || [])
.map((e) => `${e.ts} ${e.level.toUpperCase().padEnd(5)} [${e.scope}] ${e.message}${e.data !== undefined ? " " + safeJson(e.data) : ""}`)
.join("\n")
}
return window.__iotpLogs
}
function safeJson(v: unknown): string {
try { return JSON.stringify(v) } catch { return String(v) }
}
function emit(level: LogLevel, scope: string, message: string, data?: unknown) {
if (ORDER[level] < ORDER[configuredLevel()]) return
const ts = new Date().toISOString()
const buf = buffer()
buf.push({ ts, level, scope, message, data })
if (buf.length > BUFFER_SIZE) buf.splice(0, buf.length - BUFFER_SIZE)
const line = `[iotp ${ts.slice(11, 23)}] [${scope}] ${message}`
const fn = level === "error" ? console.error : level === "warn" ? console.warn : level === "info" ? console.info : console.debug
if (data !== undefined) fn(line, data)
else fn(line)
}
export function getLogger(scope: string) {
return {
debug: (message: string, data?: unknown) => emit("debug", scope, message, data),
info: (message: string, data?: unknown) => emit("info", scope, message, data),
warn: (message: string, data?: unknown) => emit("warn", scope, message, data),
error: (message: string, data?: unknown) => emit("error", scope, message, data),
}
}
// Describe any thrown value for logs and user messages.
export function describeError(err: unknown): string {
if (err instanceof Error) return `${err.name}: ${err.message}`
if (typeof err === "string") return err
return safeJson(err)
}
// Global handlers so uncaught browser errors land in the buffer too.
export function installGlobalErrorLogging() {
if (typeof window === "undefined") return
const w = window as Window & { __iotpGlobalInstalled?: boolean }
if (w.__iotpGlobalInstalled) return
w.__iotpGlobalInstalled = true
const log = getLogger("window")
window.addEventListener("error", (ev) => log.error(`Uncaught: ${ev.message}`, { file: ev.filename, line: ev.lineno }))
window.addEventListener("unhandledrejection", (ev) => log.error(`Unhandled rejection: ${describeError(ev.reason)}`))
log.debug(`Logging active at level "${configuredLevel()}"; call window.__iotpDumpLogs() to copy the buffer`)
}