alot more changes
This commit is contained in:
1 parent
23243ddbdc
commit
0ed2935f04
40 files changed
+2889
-115
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { useIssueSources } from "../hooks/useIssueSources"
|
||||
import { authFetch } from "../lib/auth"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import {
|
||||
@@ -147,7 +148,7 @@ export const toneLine: Record<SeverityTone, string> = {
|
||||
|
||||
/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */
|
||||
export const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||||
const { points, max, reference, breachIndex } = sparklineSeries(issue, 24)
|
||||
const { points, hours, max, reference, breachIndex } = sparklineSeries(issue, 24)
|
||||
const width = 100
|
||||
const height = 100
|
||||
const step = width / (points.length - 1)
|
||||
@@ -167,8 +168,8 @@ export const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||||
const bandHeight = Math.max(height - refY - 4, 6)
|
||||
|
||||
const description = hasBreach
|
||||
? `Hourly occurrence count over the last 24 hours, climbing above the period average roughly ${points.length - breachIndex} hours ago and still elevated`
|
||||
: `Hourly occurrence count over the last 24 hours, staying within the period average`
|
||||
? `Hourly occurrence count over an adaptive ${hours}-hour activity window, climbing above the period average roughly ${points.length - breachIndex} hours ago and still elevated`
|
||||
: `Hourly occurrence count over an adaptive ${hours}-hour activity window, staying within the period average`
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -316,6 +317,7 @@ const IssueCard = ({
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
||||
const [needleLoading, setNeedleLoading] = useState(false)
|
||||
const { sources, loading: sourcesLoading, error: sourcesError } = useIssueSources(issue.id, expanded)
|
||||
|
||||
const handleNeedleTriage = async () => {
|
||||
setNeedleLoading(true)
|
||||
@@ -545,6 +547,34 @@ const IssueCard = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||
All sources ({sources.length})
|
||||
</p>
|
||||
{sourcesLoading && <span className="text-[11px] text-ds-text-3">Loading…</span>}
|
||||
</div>
|
||||
{sourcesError && <p className="text-[12px] text-ds-critical">Failed to load sources: {sourcesError}</p>}
|
||||
{!sourcesLoading && !sourcesError && sources.length === 0 && (
|
||||
<p className="text-[12px] text-ds-text-3">No source IP was recorded for these occurrences.</p>
|
||||
)}
|
||||
{sources.length > 0 && (
|
||||
<div className="overflow-hidden rounded-[10px] border border-ds-line bg-ds-surface">
|
||||
{sources.map((source) => (
|
||||
<div key={source.ip} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-ds-line px-3 py-2 last:border-b-0">
|
||||
<span className="min-w-[150px] font-[family-name:var(--font-data)] text-[12px] text-ds-text">
|
||||
<IpButton ip={source.ip} />
|
||||
</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-2">×{source.count}</span>
|
||||
<span className="text-[11px] text-ds-text-3">
|
||||
{new Date(source.firstSeen).toLocaleString()} → {new Date(source.lastSeen).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||
Recent occurrences ({issue.samples.length})
|
||||
|
||||
@@ -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<SortMode, (a: SourceIp, b: SourceIp) => 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<SortMode, string> = { recent: "latest first", requests: "most requests", errors: "most errors", first: "newest addresses" }
|
||||
|
||||
const SEEN_WINDOWS: Record<string, { label: string; ms: number }> = {
|
||||
"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<DetectionKind, string> = { probing: "probing", "block-candidate": "block candidate", blocked: "blocked" }
|
||||
const reasonLabel: Record<Exclude<BadActorReason, "">, 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 }) => (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border border-ds-critical/40 bg-ds-critical-bg px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-critical">
|
||||
<ShieldAlert className="h-3 w-3" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
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<string, string> }[]
|
||||
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 }) => (
|
||||
<details className="mt-2 text-[11px] text-ds-text-3">
|
||||
<summary className="cursor-pointer">Scan details · {(debug.durationMs / 1000).toFixed(1)}s{debug.hostState ? ` · host ${debug.hostState}` : ""}</summary>
|
||||
<dl className="mt-1.5 flex flex-col gap-1 font-[family-name:var(--font-data)] break-all">
|
||||
<div><dt className="inline">command: </dt><dd className="inline">{debug.command}</dd></div>
|
||||
{debug.summary && <div><dt className="inline">nmap: </dt><dd className="inline">{debug.summary}</dd></div>}
|
||||
{debug.exitError && <div><dt className="inline">exit: </dt><dd className="inline">{debug.exitError}{debug.timedOut ? " (deadline)" : ""}</dd></div>}
|
||||
{debug.stderr && <div><dt className="inline">stderr: </dt><dd className="inline whitespace-pre-wrap">{debug.stderr}</dd></div>}
|
||||
</dl>
|
||||
</details>
|
||||
)
|
||||
|
||||
const InvestigationActions = ({ ip }: { ip: string }) => {
|
||||
const [starting, setStarting] = useState<"passive" | "active" | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [errorDebug, setErrorDebug] = useState<ScanDebug | null>(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<Record<"passive" | "active", InvestigationRecord>> }>(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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Investigate</SectionLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void investigate("passive")}
|
||||
disabled={anyRunning}
|
||||
className="inline-flex items-center gap-1.5 rounded-[7px] border border-ds-line-strong px-2.5 py-1.5 text-[11.5px] text-ds-text-2 hover:border-ds-accent hover:text-ds-accent disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{running("passive") ? "Looking up…" : passiveRec ? "Refresh DNS + RDAP" : "Reverse DNS + RDAP"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void investigate("active")}
|
||||
disabled={anyRunning || !itemIsPublic(ip)}
|
||||
className="inline-flex items-center gap-1.5 rounded-[7px] border border-ds-line-strong px-2.5 py-1.5 text-[11.5px] text-ds-text-2 hover:border-ds-accent hover:text-ds-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Runs a bounded Nmap service probe when enabled on the server"
|
||||
>
|
||||
<Radio className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{running("active") ? `Probing… ${elapsed(activeRec)}s` : activeRec ? "Probe again" : "Probe services"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-ds-text-3">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.</p>
|
||||
{error && <p className="text-[12px] text-ds-critical">Investigation failed: {error}</p>}
|
||||
{errorDebug && <DebugPanel debug={errorDebug} />}
|
||||
{failed.map((r) => (
|
||||
<div key={r.mode}>
|
||||
<p className="text-[12px] text-ds-critical">{r.mode === "active" ? "Probe" : "Lookup"} failed: {r.error}</p>
|
||||
{r.debug && <DebugPanel debug={r.debug} />}
|
||||
</div>
|
||||
))}
|
||||
{(passiveRec?.finishedAt || activeRec?.finishedAt) && (
|
||||
<p className="text-[11px] text-ds-text-3">
|
||||
Saved results: {[passiveRec, activeRec].filter((r) => r?.status === "done" && r.finishedAt).map((r) => `${r!.mode} ${dateTime(r!.finishedAt!)}`).join(" · ") || "none successful"}
|
||||
</p>
|
||||
)}
|
||||
{passive && (
|
||||
<dl className="grid grid-cols-2 gap-3 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
||||
<DetailField label="Reverse DNS" value={passive.hostnames?.join(", ") || "No PTR record"} />
|
||||
<DetailField label="Registry" value={rdap?.registry} />
|
||||
<DetailField label="Network" value={rdap?.name || rdap?.handle} />
|
||||
<DetailField label="Organization" value={rdap?.organization} />
|
||||
<DetailField label="Abuse contact" value={rdap?.abuseEmail} />
|
||||
<DetailField label="Country" value={rdap?.country} />
|
||||
<DetailField label="Range" value={rdap ? `${rdap.start || "?"} – ${rdap.end || "?"}` : undefined} />
|
||||
</dl>
|
||||
)}
|
||||
{passive?.warnings?.map((warning) => <p key={warning} className="text-[11px] text-ds-warn">{warning}</p>)}
|
||||
{active && (
|
||||
<div className="rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
||||
<p className="mb-2 font-semibold text-ds-text">Open services</p>
|
||||
{active.services?.length ? (
|
||||
<ul className="flex flex-col gap-1 font-[family-name:var(--font-data)] text-ds-text-2">
|
||||
{active.services.map((service) => (
|
||||
<li key={`${service.protocol}/${service.port}`}>
|
||||
{service.port}/{service.protocol} · {service.name || "unknown"}{service.product ? ` · ${service.product}` : ""}{service.version ? ` ${service.version}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-ds-text-3">No open services found in the top 100 ports{active.debug?.hostState ? ` (host ${active.debug.hostState})` : ""}.</p>}
|
||||
{active.services?.some((sv) => sv.scripts) && (
|
||||
<ul className="mt-2 flex flex-col gap-1.5 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||
{active.services.flatMap((sv) => Object.entries(sv.scripts ?? {}).map(([id, out]) => (
|
||||
<li key={`${sv.port}-${id}`} className="whitespace-pre-wrap break-all"><span className="text-ds-text-2">{sv.port} {id}:</span> {out}</li>
|
||||
)))}
|
||||
</ul>
|
||||
)}
|
||||
{active.debug && <DebugPanel debug={active.debug} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BlockIpAction = ({ ip, alreadyBlocked }: { ip: string; alreadyBlocked: boolean }) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionLabel>Blocking</SectionLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addToBlockList()}
|
||||
disabled={loading || alreadyBlocked || !itemIsPublic(ip)}
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-[7px] border border-ds-line-strong px-2.5 py-1.5 text-[11.5px] text-ds-text-2 hover:border-ds-critical hover:text-ds-critical disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Add this public IP to the managed ingress block list"
|
||||
>
|
||||
<ShieldBan className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{alreadyBlocked ? "On block list" : loading ? "Adding…" : "Add to block list"}
|
||||
</button>
|
||||
<p className="text-[11px] text-ds-text-3">Blocks expire automatically according to the server policy.</p>
|
||||
{message && <p className="text-[12px] text-ds-ok">{message}</p>}
|
||||
{error && <p className="text-[12px] text-ds-critical">Block failed: {error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<AbuseCheck | null>(null)
|
||||
const [category, setCategory] = useState("14")
|
||||
const [comment, setComment] = useState("")
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>AbuseIPDB</SectionLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runCheck()}
|
||||
disabled={checking || reporting || !itemIsPublic(ip)}
|
||||
className="inline-flex items-center gap-1.5 rounded-[7px] border border-ds-line-strong px-2.5 py-1.5 text-[11.5px] text-ds-text-2 hover:border-ds-accent hover:text-ds-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{checking ? "Checking…" : "Check reputation"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-ds-text-3">Checks use the last 90 days. Reporting sends this address and your comment to AbuseIPDB.</p>
|
||||
{check && (
|
||||
<dl className="grid grid-cols-2 gap-3 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px] sm:grid-cols-3">
|
||||
<DetailField label="Confidence" value={`${check.abuseConfidenceScore ?? 0}%`} />
|
||||
<DetailField label="Reports" value={`${check.totalReports ?? 0} · ${check.numDistinctUsers ?? 0} reporters`} />
|
||||
<DetailField label="Country" value={check.countryCode} />
|
||||
<DetailField label="Usage" value={check.usageType} />
|
||||
<DetailField label="ISP" value={check.isp} />
|
||||
<DetailField label="Last reported" value={check.lastReportedAt ? dateTime(check.lastReportedAt) : "Never"} />
|
||||
{check.isWhitelisted && <DetailField label="Whitelist" value="Yes" />}
|
||||
</dl>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-ds-text-3">
|
||||
Category
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-[6px] border border-ds-line-strong bg-ds-surface px-2 py-1.5 text-[12px] text-ds-text">
|
||||
{abuseCategories.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-ds-text-3">
|
||||
Comment <span className="text-ds-text-3">(optional, no personal data)</span>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} maxLength={1000} rows={2} placeholder="What did this address do?" className="resize-y rounded-[6px] border border-ds-line-strong bg-ds-surface px-2 py-1.5 text-[12px] text-ds-text placeholder:text-ds-text-3" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void report()}
|
||||
disabled={checking || reporting || !itemIsPublic(ip)}
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-[7px] border border-ds-line-strong px-2.5 py-1.5 text-[11.5px] text-ds-text-2 hover:border-ds-critical hover:text-ds-critical disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{reporting ? "Reporting…" : "Report address"}
|
||||
</button>
|
||||
</div>
|
||||
{message && <p className="text-[12px] text-ds-ok">{message}</p>}
|
||||
{error && <p className="whitespace-pre-wrap text-[12px] text-ds-critical">AbuseIPDB failed: {error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Exclude<BadActorReason, "">, 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[] }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${isFlagged(item, detections) || item.errors > 0 ? "bg-ds-critical" : "bg-ds-line-strong"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate font-[family-name:var(--font-data)] text-[12.5px] font-medium text-ds-text">{item.ip}</span>
|
||||
<span className="flex-grow" />
|
||||
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{formatRelativeTime(ms(item.lastSeen))}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||
<span className="shrink-0 whitespace-nowrap">{item.requests.toLocaleString()} req</span>
|
||||
{item.errors > 0 && <span className="shrink-0 whitespace-nowrap text-ds-critical">{item.errors.toLocaleString()} err</span>}
|
||||
{item.badActor && <BadActorBadge label={reasonShort[item.badActor]} />}
|
||||
{detections.map((d) => (
|
||||
<BadActorBadge key={d} label={detectionLabel[d]} />
|
||||
))}
|
||||
{item.uaClass && (
|
||||
<span className="shrink-0 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-1.5 py-0.5 text-[10.5px] text-ds-text-2">
|
||||
{uaClassLabel(item.uaClass)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-[family-name:var(--font-data)] text-[14px] font-semibold text-ds-text">{item.ip}</span>
|
||||
<span className="text-[11.5px] text-ds-text-3">
|
||||
{item.public ? "Public address" : "Private / reserved address - likely a proxy hop, not a client"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(detections.length > 0 || badActor) && (
|
||||
<div role="note" className="flex flex-col gap-1.5 rounded-[10px] border border-ds-critical/40 bg-ds-critical-bg p-3 text-[12px] text-ds-critical">
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold">
|
||||
<ShieldAlert className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Possible bad actor
|
||||
</span>
|
||||
<ul className="flex flex-col gap-0.5 text-ds-text-2">
|
||||
{detections.includes("probing") && <li>Honeypot promoted this address after repeated scanner-path hits.</li>}
|
||||
{detections.includes("block-candidate") && <li>The IP blocker lists it as a block candidate.</li>}
|
||||
{detections.includes("blocked") && <li>The IP blocker currently enforces a block on it.</li>}
|
||||
{badActor && (
|
||||
<li>
|
||||
{reasonLabel[badActor]}
|
||||
{probePaths > 0 && ` (${probePaths} distinct probe path${probePaths === 1 ? "" : "s"} in the window below)`}.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-y border-ds-fill py-3 sm:grid-cols-3">
|
||||
<DetailField label="First seen" value={dateTime(item.firstSeen)} />
|
||||
<DetailField label="Last seen" value={dateTime(item.lastSeen)} />
|
||||
<DetailField label="Requests" value={item.requests.toLocaleString()} />
|
||||
<DetailField label="Errors" value={item.errors.toLocaleString()} />
|
||||
<DetailField label="Client class" value={uaClassLabel(item.uaClass)} />
|
||||
<DetailField label="Services" value={item.services.join(", ")} />
|
||||
<DetailField label="Last user agent" value={item.userAgent} />
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>IP data</SectionLabel>
|
||||
<IpInfoPanel ip={item.ip} />
|
||||
</div>
|
||||
|
||||
<InvestigationActions key={item.ip} ip={item.ip} />
|
||||
|
||||
<AbuseIpDbActions key={`abuse-${item.ip}`} ip={item.ip} />
|
||||
|
||||
<BlockIpAction ip={item.ip} alreadyBlocked={detections.includes("blocked")} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Recent requests · last 7 days</SectionLabel>
|
||||
{loading ? (
|
||||
<p className="text-[12px] text-ds-text-3">Loading activity…</p>
|
||||
) : error ? (
|
||||
<p className="text-[12px] text-ds-critical">Could not load activity.</p>
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-[12px] text-ds-text-3">No requests from this address in the last 7 days (raw events expire; the totals above are lifetime).</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-ds-fill">
|
||||
{events.map((e) => (
|
||||
<li key={`${e.traceId}-${e.spanId}`} className="flex items-center gap-2 py-1.5 font-[family-name:var(--font-data)] text-[11.5px]">
|
||||
<span className="w-[112px] shrink-0 whitespace-nowrap text-ds-text-3">{dateTime(e.timestamp)}</span>
|
||||
<span className="w-9 shrink-0 text-ds-text-2">{e.method}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ds-text" title={`${e.host ?? ""}${e.route || e.spanName || ""}`}>
|
||||
{e.host && <span className="text-ds-text-3">{e.host}</span>}
|
||||
{e.route || e.spanName}
|
||||
</span>
|
||||
<span className={`shrink-0 whitespace-nowrap ${e.isError ? "text-ds-critical" : "text-ds-text-2"}`}>
|
||||
{e.statusCode || "-"}
|
||||
</span>
|
||||
{e.traceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrace(e.traceId)}
|
||||
aria-label={`Open trace ${e.traceId}`}
|
||||
className="shrink-0 rounded-[6px] border border-ds-line-strong px-1.5 py-0.5 text-[10.5px] text-ds-accent hover:bg-ds-accent-bg"
|
||||
>
|
||||
trace
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceIpsVisualizer() {
|
||||
const { ips, loading, error, refresh } = useSourceIps()
|
||||
const { searchQuery, setSearchQuery } = useObservabilityData()
|
||||
const { segments, go } = useSubRoute()
|
||||
const detected = useDetectedIps()
|
||||
const [scope, setScope] = useState<Scope>("public")
|
||||
const [sortMode, setSortMode] = useState<SortMode>("recent")
|
||||
const [service, setService] = useState<string | null>(null)
|
||||
const [uaClass, setUaClass] = useState<string | null>(null)
|
||||
const [seen, setSeen] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
|
||||
<Fingerprint className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">Source addresses unavailable</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">This list needs ClickHouse storage (OTEL_CLICKHOUSE_ADDR).</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (loading && ips.length === 0) {
|
||||
return <div className="py-16 text-center text-sm text-ds-text-3">Loading source addresses…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
scope={{
|
||||
label: "Address scope",
|
||||
value: scope,
|
||||
onChange: (v) => 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 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
|
||||
<Fingerprint className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[13px] text-ds-text-3">{ips.length === 0 ? "No source addresses recorded yet." : "No addresses match your filters."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<MasterDetail
|
||||
items={filtered}
|
||||
getKey={(i) => i.ip}
|
||||
selectedKey={selected?.ip ?? null}
|
||||
onSelect={(ip) => go([ip])}
|
||||
label="Source addresses"
|
||||
storageKey="source-ips"
|
||||
listSize={{ default: 380, min: 300, max: 560 }}
|
||||
listHeader={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">Source IPs · {sortLabel[sortMode]}</h2>
|
||||
<Segmented
|
||||
label="Sort source addresses"
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
options={[
|
||||
{ value: "recent", label: "Recent" },
|
||||
{ value: "requests", label: "Requests" },
|
||||
{ value: "errors", label: "Errors" },
|
||||
{ value: "first", label: "New" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
aria-label="Refresh source addresses"
|
||||
title="Refresh source addresses"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-ds-text-3 hover:bg-ds-fill hover:text-ds-text-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
renderItem={(item) => <IpListItem item={item} detections={detected.get(item.ip) ?? []} />}
|
||||
renderDetail={(item) => <SourceIpDetail item={item} detections={detected.get(item.ip) ?? []} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourceIpsVisualizer
|
||||
@@ -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 }) => {
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-ds-text-3">Attributes</p>
|
||||
<div className="space-y-1">
|
||||
{(record.attributes ?? []).map(attr => (
|
||||
<div key={attr.key} className="flex gap-2">
|
||||
<span className="text-ds-accent shrink-0">{attr.key}:</span>
|
||||
<span className="text-ds-text-2 break-all">{formatAttributeValue(attr.value)}</span>
|
||||
</div>
|
||||
<KeyValue key={attr.key} name={attr.key} value={formatAttributeValue(attr.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,10 +230,7 @@ export const LogEntryView = ({ log }: { log: LogEntry }) => {
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-ds-text-3">Resource</p>
|
||||
<div className="space-y-1">
|
||||
{(record.resourceAttributes ?? []).map(attr => (
|
||||
<div key={attr.key} className="flex gap-2">
|
||||
<span className="text-ds-ok shrink-0">{attr.key}:</span>
|
||||
<span className="text-ds-text-2 break-all">{formatAttributeValue(attr.value)}</span>
|
||||
</div>
|
||||
<KeyValue key={attr.key} name={attr.key} value={formatAttributeValue(attr.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,6 +332,9 @@ export const TabsComponent = () => {
|
||||
<Tabs.Content className="outline-none py-2" value="tab9">
|
||||
{activeTab === "tab9" && <ViewErrorBoundary><RolloutsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab10">
|
||||
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab2"
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { TraceData, Span } from "../lib/types";
|
||||
import { formatDuration, formatRelativeTime } from "../lib/timeUtils";
|
||||
import { KeyValue } from "./common/DetailField";
|
||||
import { formatAttributeValue, getServicePalette, getSpanColor } from "../lib/otel-utils";
|
||||
import { extractEndpoint } from "../lib/http-endpoint";
|
||||
import { useLogsForTraceId } from "../hooks/useLogsForTraceId";
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData";
|
||||
import { LogEntryView } from "./Tabs";
|
||||
import { ServiceGraph, type GraphSpan } from "./ServiceGraph";
|
||||
import { IpButton } from "./common/IpButton";
|
||||
|
||||
export type ProcessedSpan = Span & {
|
||||
service: string;
|
||||
@@ -156,6 +158,9 @@ const getOperationDisplayName = (span: ProcessedSpan) => {
|
||||
return name || "span";
|
||||
};
|
||||
|
||||
const isIpAddress = (value: string): boolean =>
|
||||
/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(":");
|
||||
|
||||
// Span Detail Inspector Drawer
|
||||
export const SpanInspector = ({
|
||||
span,
|
||||
@@ -427,7 +432,7 @@ export const SpanInspector = ({
|
||||
className="flex flex-col gap-1 rounded-xl border border-ds-line bg-ds-surface-2 p-2.5 transition-colors hover:border-ds-line-strong"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-ds-accent font-bold break-all">{key}</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3 break-all">{key}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggleExcludedValue(key, value)}
|
||||
@@ -449,7 +454,19 @@ export const SpanInspector = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-ds-text-2 break-all select-all">{value}</span>
|
||||
{key === "net.peer.ip" && isIpAddress(value) ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<IpButton ip={value} />
|
||||
<a
|
||||
href={`/ips/${encodeURIComponent(value)}`}
|
||||
className="text-[11px] text-ds-accent underline underline-offset-2 hover:text-ds-text"
|
||||
>
|
||||
View source IPs
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-[family-name:var(--font-data)] text-[12.5px] font-medium text-ds-text break-all select-all">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -478,12 +495,7 @@ export const SpanInspector = ({
|
||||
{event.attributes && event.attributes.length > 0 && (
|
||||
<div className="space-y-1 pt-1 border-t border-ds-line">
|
||||
{event.attributes.map((attr, aIdx) => (
|
||||
<div key={aIdx} className="font-mono text-[11px] flex gap-2">
|
||||
<span className="text-ds-text-3">{attr.key}:</span>
|
||||
<span className="text-ds-text-2 break-all">
|
||||
{formatAttributeValue(attr.value)}
|
||||
</span>
|
||||
</div>
|
||||
<KeyValue key={aIdx} name={attr.key} value={formatAttributeValue(attr.value)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,10 +3,23 @@ export const DetailField = ({ label, value }: { label: string; value?: string |
|
||||
value === undefined || value === "" ? null : (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{label}</dt>
|
||||
<dd className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">{value}</dd>
|
||||
<dd className="mt-0.5 break-all font-[family-name:var(--font-data)] text-[12.5px] font-medium text-ds-text">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{children}</p>
|
||||
)
|
||||
|
||||
/**
|
||||
* One `key = value` row for attribute lists: the key recedes (muted, never
|
||||
* dimmer than the accessibility floor) and the value is full-strength, so a
|
||||
* long list scans by its values. Wrapping the value keeps long ids readable.
|
||||
*/
|
||||
export const KeyValue = ({ name, value }: { name: string; value: React.ReactNode }) => (
|
||||
<div className="flex gap-1.5 font-[family-name:var(--font-data)] text-[11.5px] leading-snug">
|
||||
<span className="shrink-0 text-ds-text-3">{name}</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-ds-text-3">=</span>
|
||||
<span className="min-w-0 break-all text-ds-text">{value}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -23,17 +23,13 @@ interface IpInfo {
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; info: IpInfo }
|
||||
|
||||
const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
/** Geo + ASN lookup (local MaxMind data) for one address, with its own loading/error states. */
|
||||
export const IpInfoPanel = ({ ip }: { ip: string }) => {
|
||||
const [state, setState] = useState<State>({ status: "loading" })
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current
|
||||
if (dialog && !dialog.open) dialog.showModal()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: "loading" })
|
||||
authFetch(`/api/ip/${encodeURIComponent(ip)}`)
|
||||
.then(async (resp) => {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
@@ -50,6 +46,51 @@ const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
|
||||
const place = info ? [info.city, info.region, info.country].filter(Boolean).join(", ") : ""
|
||||
const hasGeo = Boolean(place || info?.asn || info?.network)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-[12.5px] text-ds-text-2">
|
||||
{state.status === "loading" && <p>Looking up…</p>}
|
||||
{state.status === "error" && <p className="text-ds-critical">Lookup failed: {state.message}</p>}
|
||||
{info && !info.configured && (
|
||||
<p>
|
||||
No MaxMind database is configured. Set <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_CITY_DB</code>{" "}
|
||||
and/or <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_ASN_DB</code> to the paths of GeoLite2
|
||||
<code className="font-[family-name:var(--font-data)]"> .mmdb</code> files.
|
||||
</p>
|
||||
)}
|
||||
{info?.scope && <p>This is a {info.scope} address, so it has no public location.</p>}
|
||||
{info?.configured && !info.scope && !hasGeo && <p>The database has no record for this address.</p>}
|
||||
{info && hasGeo && (
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
<DetailField label="Location" value={place} />
|
||||
<DetailField label="Country code" value={info.countryCode ? `${info.countryCode}${info.inEu ? " (EU)" : ""}` : ""} />
|
||||
<DetailField label="Postal" value={info.postal} />
|
||||
<DetailField label="Timezone" value={info.timezone} />
|
||||
<DetailField label="ASN" value={info.asn ? `AS${info.asn}` : ""} />
|
||||
<DetailField label="Organisation" value={info.asOrg} />
|
||||
<DetailField label="Network" value={info.network} />
|
||||
<DetailField
|
||||
label="Coordinates"
|
||||
value={
|
||||
info.latitude !== undefined && info.longitude !== undefined ? `${info.latitude.toFixed(2)}, ${info.longitude.toFixed(2)}` : ""
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
)}
|
||||
{hasGeo && (
|
||||
<p className="text-[11px] text-ds-text-3">Approximate, from the local MaxMind database. Not precise enough to identify a person.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current
|
||||
if (dialog && !dialog.open) dialog.showModal()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
@@ -59,10 +100,7 @@ const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
|
||||
className="m-auto w-[min(92vw,460px)] rounded-[14px] border border-ds-line bg-ds-surface p-0 text-ds-text backdrop:bg-ds-bg/70"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-ds-line px-4 py-3">
|
||||
<h2
|
||||
id="ip-dialog-title"
|
||||
className="break-all font-[family-name:var(--font-display)] text-[15px] font-semibold"
|
||||
>
|
||||
<h2 id="ip-dialog-title" className="break-all font-[family-name:var(--font-display)] text-[15px] font-semibold">
|
||||
<span className="font-[family-name:var(--font-data)] text-[13px]">{ip}</span>
|
||||
</h2>
|
||||
<button
|
||||
@@ -74,43 +112,8 @@ const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-4 text-[12.5px] text-ds-text-2">
|
||||
{state.status === "loading" && <p>Looking up…</p>}
|
||||
{state.status === "error" && <p className="text-ds-critical">Lookup failed: {state.message}</p>}
|
||||
{info && !info.configured && (
|
||||
<p>
|
||||
No MaxMind database is configured. Set <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_CITY_DB</code>{" "}
|
||||
and/or <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_ASN_DB</code> to the paths of GeoLite2
|
||||
<code className="font-[family-name:var(--font-data)]"> .mmdb</code> files.
|
||||
</p>
|
||||
)}
|
||||
{info?.scope && <p>This is a {info.scope} address, so it has no public location.</p>}
|
||||
{info?.configured && !info.scope && !hasGeo && <p>The database has no record for this address.</p>}
|
||||
{info && hasGeo && (
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
<DetailField label="Location" value={place} />
|
||||
<DetailField
|
||||
label="Country code"
|
||||
value={info.countryCode ? `${info.countryCode}${info.inEu ? " (EU)" : ""}` : ""}
|
||||
/>
|
||||
<DetailField label="Postal" value={info.postal} />
|
||||
<DetailField label="Timezone" value={info.timezone} />
|
||||
<DetailField label="ASN" value={info.asn ? `AS${info.asn}` : ""} />
|
||||
<DetailField label="Organisation" value={info.asOrg} />
|
||||
<DetailField label="Network" value={info.network} />
|
||||
<DetailField
|
||||
label="Coordinates"
|
||||
value={
|
||||
info.latitude !== undefined && info.longitude !== undefined
|
||||
? `${info.latitude.toFixed(2)}, ${info.longitude.toFixed(2)}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
)}
|
||||
{hasGeo && (
|
||||
<p className="text-[11px] text-ds-text-3">Approximate, from the local MaxMind database. Not precise enough to identify a person.</p>
|
||||
)}
|
||||
<div className="px-4 py-4">
|
||||
<IpInfoPanel ip={ip} />
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
AlertOctagon,
|
||||
CloudCog,
|
||||
Fingerprint,
|
||||
GitBranch,
|
||||
Globe,
|
||||
LineChart,
|
||||
@@ -62,6 +63,7 @@ export const IconRail: React.FC = () => {
|
||||
{ tab: "tab3", label: "Live log stream", Icon: ScrollText, dotTone: "warn", dotVisible: errorLogCount > 0 },
|
||||
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
||||
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
||||
{ tab: "tab10", label: "Source IPs", Icon: Fingerprint },
|
||||
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
||||
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user