alot more changes
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 36s

This commit is contained in:
mats committed 2026-09-21 18:59:13 +02:00
1 parent 23243ddbdc
commit 0ed2935f04
40 files changed
+2889 -115

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ function App() {
// The redesigned screens (issues, logs, RUM sessions, rollouts, metrics) own
// their own filter bar and cards, so the legacy anomaly banner and volume
// waveform above them would just duplicate that UI in a different visual language.
const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7" || activeTab === "tab9" || activeTab === "tab2";
const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7" || activeTab === "tab9" || activeTab === "tab10" || activeTab === "tab2";
if (isCallback) {
return <AuthCallbackView />;
+33 -3
View File
@@ -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
+7 -8
View File
@@ -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 -8
View File
@@ -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>
)}
+14 -1
View File
@@ -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>
)
+51 -48
View File
@@ -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 },
]
+41
View File
@@ -0,0 +1,41 @@
import useSWR from "swr"
import { authFetch, tokens } from "../lib/auth"
export type DetectionKind = "probing" | "block-candidate" | "blocked"
interface HoneypotIp {
ip: string
count: number
paths: number
}
interface BlockerStatus {
dryRun: boolean
candidates: string[]
managed: Record<string, number>
}
/**
* Live bad-actor signals the server already keeps: addresses the honeypot
* promoted for probing, and the blocker's candidate / enforced lists. The
* blocker route only exists when the blocker is enabled, so its absence is
* not an error. Returns ip -> detections.
*/
export function useDetectedIps(): Map<string, DetectionKind[]> {
const { data } = useSWR(
tokens() ? "detected-ips" : null,
async () => {
const [honeypot, blocker] = await Promise.all([
authFetch("/api/honeypot/ips").then((r) => (r.ok ? (r.json() as Promise<HoneypotIp[]>) : [])).catch(() => []),
authFetch("/api/blocker/status").then((r) => (r.ok ? (r.json() as Promise<BlockerStatus>) : null)).catch(() => null),
])
const out = new Map<string, DetectionKind[]>()
const add = (ip: string, kind: DetectionKind) => out.set(ip, [...(out.get(ip) ?? []), kind])
for (const h of honeypot ?? []) add(h.ip, "probing")
for (const ip of blocker?.candidates ?? []) add(ip, "block-candidate")
for (const ip of Object.keys(blocker?.managed ?? {})) add(ip, "blocked")
return out
},
{ refreshInterval: 60_000, revalidateOnFocus: false, shouldRetryOnError: false },
)
return data ?? new Map()
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react"
import { authFetch } from "../lib/auth"
export interface IssueSource {
ip: string
firstSeen: string
lastSeen: string
count: number
}
/** Loads the complete source-IP aggregate, independent of the bounded samples. */
export function useIssueSources(issueId: string, enabled = true) {
const [sources, setSources] = useState<IssueSource[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled) return
let cancelled = false
setLoading(true)
authFetch(`/api/issues/${encodeURIComponent(issueId)}/sources`)
.then(async (res) => {
if (res.status === 501) return []
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as IssueSource[]
})
.then((data) => {
if (!cancelled) {
setSources(data)
setError(null)
}
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [issueId, enabled])
return { sources, loading, error }
}
+21
View File
@@ -0,0 +1,21 @@
import useSWR from "swr"
import { authSWRFetcher, tokens } from "../lib/auth"
import type { BadActorReason, SourceIp, SourceIpEvent } from "../lib/types"
/** Global list of unique source addresses (lifetime totals), newest first. */
export function useSourceIps() {
const key = tokens() ? "/api/source-ips?limit=1000" : null
const state = useSWR<{ ips: SourceIp[] }>(key, authSWRFetcher, {
refreshInterval: 60_000,
revalidateOnFocus: false,
shouldRetryOnError: false,
})
return { ips: state.data?.ips ?? [], loading: state.isLoading, error: state.error, refresh: state.mutate }
}
/** What one address has requested, newest first. Only fetched once an ip is selected. */
export function useSourceIpActivity(ip: string | null, hours: number) {
const key = ip && tokens() ? `/api/source-ips/${encodeURIComponent(ip)}?hours=${hours}&limit=300` : null
const state = useSWR<{ events: SourceIpEvent[]; badActor: BadActorReason; probePaths: number }>(key, authSWRFetcher, { revalidateOnFocus: false, shouldRetryOnError: false })
return { events: state.data?.events ?? [], badActor: state.data?.badActor ?? "", probePaths: state.data?.probePaths ?? 0, loading: state.isLoading, error: state.error }
}
+39 -2
View File
@@ -25,6 +25,42 @@ export function trendPoints(issue: Issue, hours = 24): TrendPoint[] {
return points
}
/**
* Picks a compact chart window around real activity. A quiet 24-hour period
* remains a useful context, but a short burst should not be reduced to a tiny
* pair of points at the edge of a mostly empty chart.
*/
export function trendWindow(issue: Issue, maxHours = 24): { start: Date; hours: number } {
const activeHours = Object.entries(issue.trend ?? {})
.filter(([, count]) => count > 0)
.map(([hour]) => new Date(`${hour}:00Z`))
.filter((date) => !Number.isNaN(date.getTime()))
.sort((a, b) => a.getTime() - b.getTime())
const now = new Date()
const currentHour = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours()))
if (activeHours.length === 0) return { start: new Date(currentHour.getTime() - (maxHours - 1) * 3_600_000), hours: maxHours }
const first = activeHours[0]
const last = activeHours[activeHours.length - 1]
const activityHours = Math.floor((last.getTime() - first.getTime()) / 3_600_000) + 1
const hours = Math.min(maxHours, Math.max(6, activityHours + 2))
const start = new Date(first.getTime() - Math.floor((hours - activityHours) / 2) * 3_600_000)
return { start, hours }
}
/** Dense points for the adaptive issue-card window, oldest first. */
export function trendWindowPoints(issue: Issue, maxHours = 24): TrendPoint[] {
const { start, hours } = trendWindow(issue, maxHours)
const points: TrendPoint[] = []
for (let i = 0; i < hours; i++) {
const d = new Date(start.getTime() + i * 3_600_000)
const hour = d.toISOString().slice(0, 13) + ":00"
points.push({ hour, count: issue.trend?.[hour] ?? 0 })
}
return points
}
/** True when the issue has at least one occurrence in the last hour. */
export function isSpike(issue: Issue): boolean {
const points = trendPoints(issue, 1)
@@ -167,6 +203,7 @@ export function strongestCorrelate(issue: Issue): Correlate | null {
export interface SparklineSeries {
points: TrendPoint[]
hours: number
max: number
/** Reference level (mean occurrence/hour) drawn as the expected band. */
reference: number
@@ -181,7 +218,7 @@ export interface SparklineSeries {
* over real occurrence counts, not a forecast or ML-derived expected band.
*/
export function sparklineSeries(issue: Issue, hours = 24): SparklineSeries {
const points = trendPoints(issue, hours)
const points = trendWindowPoints(issue, hours)
const max = Math.max(...points.map((p) => p.count), 1)
const mean = points.reduce((sum, p) => sum + p.count, 0) / points.length
const threshold = Math.max(mean * 1.5, mean + 1)
@@ -192,7 +229,7 @@ export function sparklineSeries(issue: Issue, hours = 24): SparklineSeries {
break
}
}
return { points, max, reference: mean, breachIndex }
return { points, hours: points.length, max, reference: mean, breachIndex }
}
/** One row per trace (all its samples grouped), plus one per traceless sample; newest first. */
+1
View File
@@ -9,6 +9,7 @@ export const TAB_PATHS: Record<string, string> = {
"/issues": "tab0",
"/patterns": "tab8",
"/rollouts": "tab9",
"/ips": "tab10",
"/traces": "tab1",
"/metrics": "tab2",
"/logs": "tab3",
+33
View File
@@ -301,3 +301,36 @@ export interface IssueTracesResponse {
/** false when the server only had the bounded in-memory samples. */
complete: boolean;
}
export interface SourceIp {
ip: string
firstSeen: string
lastSeen: string
requests: number
errors: number
services: string[]
userAgent?: string
uaClass?: string
/** false for loopback/private/reserved addresses (usually a proxy hop) */
public: boolean
/** matched bad-actor rule over the last 7 days ("" when none) */
badActor?: BadActorReason
probePaths?: number
}
export interface SourceIpEvent {
timestamp: string
service: string
method?: string
host?: string
route?: string
spanName?: string
statusCode: number
isError: boolean
traceId: string
spanId: string
userAgent?: string
}
/** Reason from the server's bad-actor rules (features.MarkBadActors). */
export type BadActorReason = "" | "probe-scanner" | "enumerator" | "bot-4xx"