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