Recent occurrences ({issue.samples.length})
diff --git a/otel-ui/src/components/SourceIpsVisualizer.tsx b/otel-ui/src/components/SourceIpsVisualizer.tsx
new file mode 100644
index 0000000..2e1cb76
--- /dev/null
+++ b/otel-ui/src/components/SourceIpsVisualizer.tsx
@@ -0,0 +1,693 @@
+import { useEffect, useMemo, useState } from "react"
+import useSWR from "swr"
+import { Fingerprint, RefreshCw, ShieldAlert, Search, Radio, ShieldBan, ShieldCheck, Flag } from "lucide-react"
+import { useSourceIps, useSourceIpActivity } from "../hooks/useSourceIps"
+import { useDetectedIps, type DetectionKind } from "../hooks/useDetectedIps"
+import { useObservabilityData } from "../hooks/useObservabilityData"
+import { formatRelativeTime } from "../lib/timeUtils"
+import { uaClassLabel } from "../lib/ua-class"
+import { IpInfoPanel } from "./common/IpButton"
+import { Segmented } from "./common/Segmented"
+import { useSubRoute } from "../lib/routes"
+import type { BadActorReason, SourceIp } from "../lib/types"
+import { FilterBar, type FilterChip } from "./FilterBar"
+import { MasterDetail } from "./MasterDetail"
+import { DetailField, SectionLabel } from "./common/DetailField"
+import { authFetch, authSWRFetcher, tokens } from "../lib/auth"
+
+type Scope = "public" | "flagged" | "all"
+type SortMode = "recent" | "requests" | "errors" | "first"
+
+const sorters: Record number> = {
+ recent: (a, b) => ms(b.lastSeen) - ms(a.lastSeen),
+ requests: (a, b) => b.requests - a.requests || ms(b.lastSeen) - ms(a.lastSeen),
+ errors: (a, b) => b.errors - a.errors || ms(b.lastSeen) - ms(a.lastSeen),
+ first: (a, b) => ms(b.firstSeen) - ms(a.firstSeen),
+}
+const sortLabel: Record = { recent: "latest first", requests: "most requests", errors: "most errors", first: "newest addresses" }
+
+const SEEN_WINDOWS: Record = {
+ "1h": { label: "last hour", ms: 3600_000 },
+ "24h": { label: "last 24 hours", ms: 24 * 3600_000 },
+ "7d": { label: "last 7 days", ms: 7 * 24 * 3600_000 },
+}
+
+const detectionLabel: Record = { probing: "probing", "block-candidate": "block candidate", blocked: "blocked" }
+const reasonLabel: Record, string> = {
+ "probe-scanner": "Probe scanner: hit several scanner paths",
+ enumerator: "Enumerator: walked many routes that do not exist",
+ "bot-4xx": "Self-declared bot getting client errors",
+}
+
+/** Critical only for real detections; a hint, not a verdict. */
+const BadActorBadge = ({ label }: { label: string }) => (
+
+
+ {label}
+
+)
+
+const ms = (iso: string) => Date.parse(iso)
+const dateTime = (iso: string) =>
+ new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" })
+
+interface InvestigationRecord {
+ mode: "passive" | "active"
+ status: "running" | "done" | "failed"
+ startedAt: string
+ finishedAt?: string
+ error?: string
+ debug?: ScanDebug
+ passive?: {
+ hostnames?: string[]
+ rdap?: { name?: string; handle?: string; start?: string; end?: string; country?: string; registry?: string; organization?: string; abuseEmail?: string }
+ warnings?: string[]
+ completedAt: string
+ }
+ active?: {
+ services?: { port: number; protocol: string; state: string; name?: string; product?: string; version?: string; scripts?: Record }[]
+ debug?: ScanDebug
+ completedAt: string
+ }
+}
+
+interface ScanDebug {
+ command: string
+ durationMs: number
+ exitError?: string
+ timedOut?: boolean
+ stderr?: string
+ hostState?: string
+ summary?: string
+ partial?: boolean
+}
+
+const DebugPanel = ({ debug }: { debug: ScanDebug }) => (
+
+ Scan details · {(debug.durationMs / 1000).toFixed(1)}s{debug.hostState ? ` · host ${debug.hostState}` : ""}
+
+ - command:
- {debug.command}
+ {debug.summary && nmap: {debug.summary}}
+ {debug.exitError && exit: {debug.exitError}{debug.timedOut ? " (deadline)" : ""}}
+ {debug.stderr && stderr: {debug.stderr}}
+
+
+)
+
+const InvestigationActions = ({ ip }: { ip: string }) => {
+ const [starting, setStarting] = useState<"passive" | "active" | null>(null)
+ const [error, setError] = useState(null)
+ const [errorDebug, setErrorDebug] = useState(null)
+ const [, setNow] = useState(0)
+
+ // Stored results load with the panel; while a job is running we poll.
+ const key = tokens() ? `/api/source-ips/${encodeURIComponent(ip)}/investigation` : null
+ const { data, mutate } = useSWR<{ records: Partial> }>(key, authSWRFetcher, {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ refreshInterval: (d) => (d && Object.values(d.records ?? {}).some((r) => r?.status === "running") ? 3000 : 0),
+ })
+ const passiveRec = data?.records?.passive
+ const activeRec = data?.records?.active
+ const running = (m: "passive" | "active") => data?.records?.[m]?.status === "running" || starting === m
+ const anyRunning = running("passive") || running("active")
+
+ useEffect(() => {
+ if (!activeRec || activeRec.status !== "running") return
+ const t = setInterval(() => setNow((n) => n + 1), 1000)
+ return () => clearInterval(t)
+ }, [activeRec])
+
+ const investigate = async (mode: "passive" | "active") => {
+ setStarting(mode)
+ setError(null)
+ setErrorDebug(null)
+ try {
+ const response = await authFetch(`/api/source-ips/${encodeURIComponent(ip)}/investigate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mode }),
+ })
+ if (!response.ok) {
+ const message = await response.text()
+ let parsed: { error?: string } | null = null
+ try { parsed = JSON.parse(message) } catch { /* plain text */ }
+ throw new Error(parsed?.error || message || `HTTP ${response.status}`)
+ }
+ await mutate()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ } finally {
+ setStarting(null)
+ }
+ }
+
+ const result = { passive: passiveRec?.passive, active: activeRec?.active }
+ const failed = [passiveRec, activeRec].filter((r): r is InvestigationRecord => r?.status === "failed")
+ const elapsed = (r?: InvestigationRecord) => (r ? Math.max(0, Math.round((Date.now() - new Date(r.startedAt).getTime()) / 1000)) : 0)
+ const passive = result.passive
+ const active = result.active
+ const rdap = passive?.rdap
+ return (
+
+
Investigate
+
+
+
+
+
Probing runs in the background (up to a few minutes) and results are saved. It is active network traffic and only available when enabled by the operator.
+ {error &&
Investigation failed: {error}
}
+ {errorDebug &&
}
+ {failed.map((r) => (
+
+
{r.mode === "active" ? "Probe" : "Lookup"} failed: {r.error}
+ {r.debug &&
}
+
+ ))}
+ {(passiveRec?.finishedAt || activeRec?.finishedAt) && (
+
+ Saved results: {[passiveRec, activeRec].filter((r) => r?.status === "done" && r.finishedAt).map((r) => `${r!.mode} ${dateTime(r!.finishedAt!)}`).join(" · ") || "none successful"}
+
+ )}
+ {passive && (
+
+
+
+
+
+
+
+
+
+ )}
+ {passive?.warnings?.map((warning) =>
{warning}
)}
+ {active && (
+
+
Open services
+ {active.services?.length ? (
+
+ {active.services.map((service) => (
+ -
+ {service.port}/{service.protocol} · {service.name || "unknown"}{service.product ? ` · ${service.product}` : ""}{service.version ? ` ${service.version}` : ""}
+
+ ))}
+
+ ) :
No open services found in the top 100 ports{active.debug?.hostState ? ` (host ${active.debug.hostState})` : ""}.
}
+ {active.services?.some((sv) => sv.scripts) && (
+
+ {active.services.flatMap((sv) => Object.entries(sv.scripts ?? {}).map(([id, out]) => (
+ - {sv.port} {id}: {out}
+ )))}
+
+ )}
+ {active.debug &&
}
+
+ )}
+
+ )
+}
+
+const BlockIpAction = ({ ip, alreadyBlocked }: { ip: string; alreadyBlocked: boolean }) => {
+ const [loading, setLoading] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [error, setError] = useState(null)
+
+ const addToBlockList = async () => {
+ setLoading(true)
+ setMessage(null)
+ setError(null)
+ try {
+ const response = await authFetch(`/api/blocker/ips/${encodeURIComponent(ip)}`, { method: "POST" })
+ const body = await response.text()
+ if (!response.ok && response.status !== 202) throw new Error(body || `HTTP ${response.status}`)
+ const status = body ? (JSON.parse(body) as { dryRun?: boolean }) : {}
+ setMessage(status.dryRun ? "Would be added (blocker is in dry-run mode)." : "Added to the block list.")
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
Blocking
+
+
Blocks expire automatically according to the server policy.
+ {message &&
{message}
}
+ {error &&
Block failed: {error}
}
+
+ )
+}
+
+interface AbuseCheck {
+ ipAddress?: string
+ isWhitelisted?: boolean
+ abuseConfidenceScore?: number
+ countryCode?: string
+ usageType?: string
+ isp?: string
+ domain?: string
+ totalReports?: number
+ numDistinctUsers?: number
+ lastReportedAt?: string
+}
+
+interface AbuseReportResult {
+ ipAddress?: string
+ abuseConfidenceScore?: number
+}
+
+const abuseCategories = [
+ [14, "Port scan"],
+ [15, "Hacking"],
+ [18, "Brute-force"],
+ [19, "Bad web bot"],
+ [20, "Exploited host"],
+ [21, "Web app attack"],
+ [22, "SSH"],
+ [23, "IoT targeted"],
+] as const
+
+const AbuseIpDbActions = ({ ip }: { ip: string }) => {
+ const [checking, setChecking] = useState(false)
+ const [check, setCheck] = useState(null)
+ const [category, setCategory] = useState("14")
+ const [comment, setComment] = useState("")
+ const [reporting, setReporting] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [error, setError] = useState(null)
+
+ const runCheck = async () => {
+ setChecking(true)
+ setError(null)
+ setMessage(null)
+ try {
+ const response = await authFetch(`/api/source-ips/${encodeURIComponent(ip)}/abuseipdb`)
+ const body = await response.text()
+ if (!response.ok) throw new Error(body || `HTTP ${response.status}`)
+ setCheck(JSON.parse(body) as AbuseCheck)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ } finally {
+ setChecking(false)
+ }
+ }
+
+ const report = async () => {
+ setReporting(true)
+ setError(null)
+ setMessage(null)
+ try {
+ const response = await authFetch(`/api/source-ips/${encodeURIComponent(ip)}/abuseipdb/report`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ categories: [Number(category)], comment }),
+ })
+ const body = await response.text()
+ if (!response.ok) throw new Error(body || `HTTP ${response.status}`)
+ const result = JSON.parse(body) as AbuseReportResult
+ setMessage(`Reported${result.abuseConfidenceScore !== undefined ? ` · confidence now ${result.abuseConfidenceScore}%` : ""}.`)
+ setComment("")
+ void runCheck()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ } finally {
+ setReporting(false)
+ }
+ }
+
+ return (
+
+
AbuseIPDB
+
+
+
+
Checks use the last 90 days. Reporting sends this address and your comment to AbuseIPDB.
+ {check && (
+
+
+
+
+
+
+
+ {check.isWhitelisted && }
+
+ )}
+
+
+
+
+
+ {message &&
{message}
}
+ {error &&
AbuseIPDB failed: {error}
}
+
+ )
+}
+
+// The active action is deliberately kept separate from the source-IP model:
+// the backend remains the authority for whether a public address is eligible.
+const itemIsPublic = (ip: string) => {
+ // SourceIpDetail only renders this action for selected rows; this prevents
+ // the obvious private-address case in the UI while the API validates again.
+ return !/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip) && ip !== "::1" && !ip.startsWith("fc") && !ip.startsWith("fd")
+}
+
+const reasonShort: Record, string> = {
+ "probe-scanner": "probe scanner",
+ enumerator: "enumerator",
+ "bot-4xx": "bot errors",
+}
+
+const isFlagged = (item: SourceIp, detections?: DetectionKind[]) => Boolean(item.badActor) || Boolean(detections?.length)
+
+const IpListItem = ({ item, detections }: { item: SourceIp; detections: DetectionKind[] }) => (
+
+
+ 0 ? "bg-ds-critical" : "bg-ds-line-strong"}`}
+ aria-hidden="true"
+ />
+ {item.ip}
+
+ {formatRelativeTime(ms(item.lastSeen))}
+
+
+ {item.requests.toLocaleString()} req
+ {item.errors > 0 && {item.errors.toLocaleString()} err}
+ {item.badActor && }
+ {detections.map((d) => (
+
+ ))}
+ {item.uaClass && (
+
+ {uaClassLabel(item.uaClass)}
+
+ )}
+
+
+)
+
+const SourceIpDetail = ({ item, detections }: { item: SourceIp; detections: DetectionKind[] }) => {
+ const { events, badActor, probePaths, loading, error } = useSourceIpActivity(item.ip, 24 * 7)
+ const { setActiveTraceId, setActiveTab } = useObservabilityData()
+ const openTrace = (traceId: string) => {
+ setActiveTraceId(traceId)
+ setActiveTab("tab1")
+ }
+ return (
+
+
+ {item.ip}
+
+ {item.public ? "Public address" : "Private / reserved address - likely a proxy hop, not a client"}
+
+
+
+ {(detections.length > 0 || badActor) && (
+
+
+
+ Possible bad actor
+
+
+ {detections.includes("probing") && - Honeypot promoted this address after repeated scanner-path hits.
}
+ {detections.includes("block-candidate") && - The IP blocker lists it as a block candidate.
}
+ {detections.includes("blocked") && - The IP blocker currently enforces a block on it.
}
+ {badActor && (
+ -
+ {reasonLabel[badActor]}
+ {probePaths > 0 && ` (${probePaths} distinct probe path${probePaths === 1 ? "" : "s"} in the window below)`}.
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ IP data
+
+
+
+
+
+
+
+
+
+
+
Recent requests · last 7 days
+ {loading ? (
+
Loading activity…
+ ) : error ? (
+
Could not load activity.
+ ) : events.length === 0 ? (
+
No requests from this address in the last 7 days (raw events expire; the totals above are lifetime).
+ ) : (
+
+ {events.map((e) => (
+ -
+ {dateTime(e.timestamp)}
+ {e.method}
+
+ {e.host && {e.host}}
+ {e.route || e.spanName}
+
+
+ {e.statusCode || "-"}
+
+ {e.traceId && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+export function SourceIpsVisualizer() {
+ const { ips, loading, error, refresh } = useSourceIps()
+ const { searchQuery, setSearchQuery } = useObservabilityData()
+ const { segments, go } = useSubRoute()
+ const detected = useDetectedIps()
+ const [scope, setScope] = useState("public")
+ const [sortMode, setSortMode] = useState("recent")
+ const [service, setService] = useState(null)
+ const [uaClass, setUaClass] = useState(null)
+ const [seen, setSeen] = useState(null)
+ const [errorsOnly, setErrorsOnly] = useState(false)
+
+ const services = useMemo(() => [...new Set(ips.flatMap((i) => i.services))].sort(), [ips])
+ const uaClasses = useMemo(() => [...new Set(ips.map((i) => i.uaClass).filter((c): c is string => Boolean(c)))].sort(), [ips])
+
+ const flaggedCount = useMemo(() => ips.filter((i) => isFlagged(i, detected.get(i.ip))).length, [ips, detected])
+
+ const filtered = useMemo(() => {
+ const q = searchQuery.trim().toLowerCase()
+ const cutoff = seen ? Date.now() - SEEN_WINDOWS[seen].ms : 0
+ return ips
+ .filter((i) => {
+ if (scope === "public" && !i.public) return false
+ if (scope === "flagged" && !isFlagged(i, detected.get(i.ip))) return false
+ if (service && !i.services.includes(service)) return false
+ if (uaClass && i.uaClass !== uaClass) return false
+ if (errorsOnly && i.errors === 0) return false
+ if (cutoff && ms(i.lastSeen) < cutoff) return false
+ if (q && !`${i.ip} ${i.services.join(" ")} ${i.userAgent ?? ""}`.toLowerCase().includes(q)) return false
+ return true
+ })
+ .sort(sorters[sortMode])
+ }, [ips, scope, detected, service, uaClass, errorsOnly, seen, searchQuery, sortMode])
+
+ const selected = filtered.find((i) => i.ip === segments[0]) ?? filtered[0] ?? null
+
+ const chip = (id: string, filterKey: string, value: string, onRemove: () => void, tone: FilterChip["tone"] = "neutral"): FilterChip => ({
+ id,
+ filterKey,
+ value,
+ tone,
+ onRemove,
+ })
+ const chips: FilterChip[] = [
+ ...(searchQuery ? [chip("query", "query", searchQuery, () => setSearchQuery(""))] : []),
+ ...(service ? [chip("service", "service", service, () => setService(null))] : []),
+ ...(uaClass ? [chip("class", "client", uaClassLabel(uaClass), () => setUaClass(null))] : []),
+ ...(seen ? [chip("seen", "seen", SEEN_WINDOWS[seen].label, () => setSeen(null))] : []),
+ ...(errorsOnly ? [chip("errors", "has", "errors", () => setErrorsOnly(false), "critical")] : []),
+ ]
+ const activeFilterCount = chips.length
+
+ const addFilterOptions = [
+ ...(errorsOnly ? [] : [{ value: "errors", label: "has errors" }]),
+ ...Object.entries(SEEN_WINDOWS)
+ .filter(([k]) => k !== seen)
+ .map(([k, w]) => ({ value: `seen:${k}`, label: `seen ${w.label}` })),
+ ...uaClasses.filter((c) => c !== uaClass).map((c) => ({ value: `class:${c}`, label: `client=${uaClassLabel(c)}` })),
+ ...services.filter((v) => v !== service).map((v) => ({ value: `svc:${v}`, label: `service=${v}` })),
+ ]
+ const onAddFilter = (value: string) => {
+ if (value === "errors") setErrorsOnly(true)
+ else if (value.startsWith("seen:")) setSeen(value.slice(5))
+ else if (value.startsWith("class:")) setUaClass(value.slice(6))
+ else if (value.startsWith("svc:")) setService(value.slice(4))
+ }
+ const clearAll = () => {
+ setSearchQuery("")
+ setService(null)
+ setUaClass(null)
+ setSeen(null)
+ setErrorsOnly(false)
+ }
+ const matchText = `Matching ${filtered.length} of ${ips.length} unique addresses · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`
+
+ if (error) {
+ return (
+
+
+
Source addresses unavailable
+
This list needs ClickHouse storage (OTEL_CLICKHOUSE_ADDR).
+
+ )
+ }
+ if (loading && ips.length === 0) {
+ return Loading source addresses…
+ }
+
+ return (
+
+
setScope(v as Scope),
+ options: [
+ { value: "public", label: "Public" },
+ { value: "flagged", label: `Flagged (${flaggedCount})` },
+ { value: "all", label: "All" },
+ ],
+ }}
+ chips={chips}
+ addFilterOptions={addFilterOptions.length > 0 ? addFilterOptions : undefined}
+ onAddFilter={addFilterOptions.length > 0 ? onAddFilter : undefined}
+ matchText={matchText}
+ onClearAll={activeFilterCount > 0 ? clearAll : undefined}
+ />
+ {filtered.length === 0 ? (
+
+
+
{ips.length === 0 ? "No source addresses recorded yet." : "No addresses match your filters."}
+
+ ) : (
+ i.ip}
+ selectedKey={selected?.ip ?? null}
+ onSelect={(ip) => go([ip])}
+ label="Source addresses"
+ storageKey="source-ips"
+ listSize={{ default: 380, min: 300, max: 560 }}
+ listHeader={
+
+
Source IPs · {sortLabel[sortMode]}
+
+
+
+ }
+ renderItem={(item) => }
+ renderDetail={(item) => }
+ />
+ )}
+
+ )
+}
+
+export default SourceIpsVisualizer
diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx
index dc77a27..fc85025 100644
--- a/otel-ui/src/components/Tabs.tsx
+++ b/otel-ui/src/components/Tabs.tsx
@@ -23,9 +23,11 @@ import { TrendsVisualizer } from "./TrendsVisualizer"
import { authFetch } from "../lib/auth"
import type { Issue, LogEntry, OtelAttribute } from "../lib/types"
import { formatAttributeValue, getServicePalette, getServiceName } from "../lib/otel-utils"
+import { KeyValue } from "./common/DetailField"
import { TelemetryFilters } from "./TelemetryFilters"
import { PatternsVisualizer } from "./PatternsVisualizer"
import { RolloutsVisualizer } from "./RolloutsVisualizer"
+import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
type SeverityMeta = {
@@ -218,10 +220,7 @@ export const LogEntryView = ({ log }: { log: LogEntry }) => {
Attributes
{(record.attributes ?? []).map(attr => (
-
- {attr.key}:
- {formatAttributeValue(attr.value)}
-
+
))}
@@ -231,10 +230,7 @@ export const LogEntryView = ({ log }: { log: LogEntry }) => {