1304 lines
54 KiB
TypeScript
1304 lines
54 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||
import {
|
||
Activity,
|
||
AlertOctagon,
|
||
Bug,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Clock,
|
||
EyeOff,
|
||
ExternalLink,
|
||
Globe,
|
||
MonitorSmartphone,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
ScrollText,
|
||
ShieldBan,
|
||
Bot,
|
||
Sparkles,
|
||
Loader2,
|
||
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 {
|
||
issueSeverityTone,
|
||
issueStatusOf,
|
||
latestBreadcrumbs,
|
||
latestStacktrace,
|
||
sampleContext,
|
||
sparklineSeries,
|
||
strongestCorrelate,
|
||
type SeverityTone,
|
||
} from "../lib/issue-utils"
|
||
import { useSubRoute } from "../lib/routes"
|
||
import { CopyRef } from "./common/CopyRef"
|
||
import { IpButton } from "./common/IpButton"
|
||
import { IssueOccurrences } from "./IssueOccurrences"
|
||
import { NeedleTestModal } from "./NeedleTestModal"
|
||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||
import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class"
|
||
import type { Issue, IssueSample } from "../lib/types"
|
||
|
||
export const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
|
||
span_error: { label: "Trace error", Icon: Activity },
|
||
log_error: { label: "Log error", Icon: ScrollText },
|
||
frontend_error: { label: "Frontend error", Icon: Globe },
|
||
}
|
||
|
||
type StatusFilter = "all" | "open" | "resolved" | "ignored"
|
||
const without = (set: Set<string>, value: string) => {
|
||
const next = new Set(set)
|
||
next.delete(value)
|
||
return next
|
||
}
|
||
|
||
type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
|
||
type GroupBy = "none" | "service"
|
||
|
||
interface HoneypotIP {
|
||
ip: string
|
||
count: number
|
||
first_seen: string
|
||
last_seen: string
|
||
promoted_until: string
|
||
}
|
||
|
||
interface BlockerStatus {
|
||
enabled: boolean
|
||
dryRun: boolean
|
||
candidates?: string[]
|
||
managed?: Record<string, number>
|
||
}
|
||
|
||
/** An IP flagged by the honeypot tracker or the ingress blocker. */
|
||
interface DetectedIP {
|
||
ip: string
|
||
sources: ("honeypot" | "blocker")[]
|
||
count?: number
|
||
}
|
||
|
||
interface DetectorTestResult {
|
||
passed?: boolean
|
||
scenario?: string
|
||
row?: {
|
||
baselineErrorRate: number
|
||
futureErrorCount: number
|
||
futureErrorRate: number
|
||
labelKnown: boolean
|
||
label: boolean
|
||
}
|
||
error?: string
|
||
}
|
||
|
||
interface NeedleResult {
|
||
action?: string
|
||
tool?: string
|
||
confidence?: number
|
||
reasoning?: string
|
||
latency_ms?: number
|
||
arguments?: Record<string, unknown>
|
||
error?: string
|
||
}
|
||
|
||
const statusTabs: { value: StatusFilter; label: string }[] = [
|
||
{ value: "all", label: "All" },
|
||
{ value: "open", label: "Open" },
|
||
{ value: "resolved", label: "Resolved" },
|
||
{ value: "ignored", label: "Ignored" },
|
||
]
|
||
|
||
const scopeOptions: { value: KindFilter; label: string }[] = [
|
||
{ value: "all", label: "All kinds" },
|
||
{ value: "span_error", label: "Traces" },
|
||
{ value: "log_error", label: "Logs" },
|
||
{ value: "frontend_error", label: "Frontend" },
|
||
]
|
||
|
||
const toneDot: Record<SeverityTone, string> = {
|
||
critical: "bg-ds-critical",
|
||
warn: "bg-ds-warn",
|
||
ok: "bg-ds-ok",
|
||
muted: "bg-ds-text-3",
|
||
}
|
||
|
||
export const tonePill: Record<SeverityTone, string> = {
|
||
critical: "bg-ds-critical-bg text-ds-critical",
|
||
warn: "bg-ds-warn-bg text-ds-warn",
|
||
ok: "bg-ds-ok/10 text-ds-ok",
|
||
muted: "bg-ds-fill text-ds-text-3",
|
||
}
|
||
|
||
const toneLabel: Record<SeverityTone, string> = {
|
||
critical: "Open",
|
||
warn: "Open",
|
||
ok: "Resolved",
|
||
muted: "Ignored",
|
||
}
|
||
|
||
export const toneLine: Record<SeverityTone, string> = {
|
||
critical: "border-l-ds-critical",
|
||
warn: "border-l-ds-warn",
|
||
ok: "border-l-ds-ok",
|
||
muted: "border-l-ds-line-strong",
|
||
}
|
||
|
||
/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */
|
||
export const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||
const { points, hours, max, reference, breachIndex } = sparklineSeries(issue, 24)
|
||
const width = 100
|
||
const height = 100
|
||
const step = width / (points.length - 1)
|
||
const toY = (v: number) => height - (v / max) * (height - 8) - 4
|
||
|
||
const observedPoints = points.map((p, i) => `${(i * step).toFixed(1)},${toY(p.count).toFixed(1)}`).join(" ")
|
||
|
||
const hasBreach = breachIndex !== null
|
||
const breachPath = hasBreach
|
||
? points
|
||
.slice(breachIndex)
|
||
.map((p, i) => `${i === 0 ? "M" : "L"}${((breachIndex + i) * step).toFixed(1)},${toY(p.count).toFixed(1)}`)
|
||
.join(" ")
|
||
: ""
|
||
const detectX = hasBreach ? breachIndex * step : null
|
||
const refY = toY(reference)
|
||
const bandHeight = Math.max(height - refY - 4, 6)
|
||
|
||
const description = hasBreach
|
||
? `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
|
||
width="100%"
|
||
height="56"
|
||
viewBox={`0 0 ${width} ${height}`}
|
||
preserveAspectRatio="none"
|
||
role="img"
|
||
aria-label={description}
|
||
>
|
||
<rect x={0} y={refY} width={width} height={bandHeight} fill="var(--ds-fill)" />
|
||
<polyline
|
||
points={observedPoints}
|
||
fill="none"
|
||
stroke="var(--ds-text-3)"
|
||
strokeWidth="1.6"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
{hasBreach && (
|
||
<path d={breachPath} fill="none" stroke="var(--ds-critical)" strokeWidth="2.2" vectorEffect="non-scaling-stroke" />
|
||
)}
|
||
{hasBreach && detectX !== null && (
|
||
<line
|
||
x1={detectX}
|
||
y1={0}
|
||
x2={detectX}
|
||
y2={height}
|
||
stroke="var(--ds-line-strong)"
|
||
strokeWidth="1"
|
||
strokeDasharray="3 4"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
)}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
export const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
|
||
<span
|
||
className={`font-[family-name:var(--font-display)] text-[18px] font-bold ${
|
||
tone ? (tone === "critical" ? "text-ds-critical" : tone === "warn" ? "text-ds-warn" : tone === "ok" ? "text-ds-ok" : "text-ds-text") : "text-ds-text"
|
||
}`}
|
||
>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
)
|
||
|
||
const StacktraceView = ({ stacktrace }: { stacktrace: string }) => (
|
||
<pre className="max-h-64 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2 whitespace-pre-wrap break-all">
|
||
{stacktrace}
|
||
</pre>
|
||
)
|
||
|
||
const BreadcrumbsView = ({ issue }: { issue: Issue }) => {
|
||
const crumbs = latestBreadcrumbs(issue)
|
||
if (!crumbs.length) return null
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<p className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||
Breadcrumbs before error
|
||
</p>
|
||
<div className="max-h-48 space-y-1 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3">
|
||
{crumbs.map((crumb, idx) => (
|
||
<div key={`${crumb.t}-${idx}`} className="flex items-baseline gap-2 font-[family-name:var(--font-data)] text-[11px]">
|
||
<span className="shrink-0 text-ds-text-3">
|
||
{new Date(crumb.t).toLocaleTimeString([], { hour12: false })}
|
||
</span>
|
||
<span className="shrink-0 rounded bg-ds-fill px-1.5 py-0.5 text-[10px] font-bold uppercase text-ds-text-2">
|
||
{crumb.category}
|
||
</span>
|
||
<span className="min-w-0 break-all text-ds-text-2">{crumb.message.slice(0, 200)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const SampleContextChips = ({ sample }: { sample: IssueSample }) => {
|
||
const ctx = sampleContext(sample)
|
||
if (!ctx.length) return null
|
||
return (
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-ds-warn/30 bg-ds-warn-bg px-2 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] font-bold text-ds-warn">
|
||
{ctx.join(" · ")}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const StatusActions = ({
|
||
issue,
|
||
onStatusChange,
|
||
busy,
|
||
}: {
|
||
issue: Issue
|
||
onStatusChange: (issue: Issue, status: string) => void
|
||
busy: boolean
|
||
}) => {
|
||
const status = issueStatusOf(issue)
|
||
const actions: { to: string; label: string; Icon: typeof CheckCircle2 }[] =
|
||
status === "open"
|
||
? [
|
||
{ to: "resolved", label: "Resolve", Icon: CheckCircle2 },
|
||
{ to: "ignored", label: "Ignore", Icon: EyeOff },
|
||
]
|
||
: [{ to: "open", label: "Reopen", Icon: RotateCcw }]
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{actions.map(({ to, label, Icon }) => (
|
||
<button
|
||
key={to}
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onStatusChange(issue, to)
|
||
}}
|
||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 transition-colors hover:bg-ds-surface-2 hover:text-ds-text disabled:opacity-50"
|
||
>
|
||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const IssueCard = ({
|
||
issue,
|
||
onStatusChange,
|
||
statusBusy,
|
||
onFocus,
|
||
detectedIPByAddress,
|
||
}: {
|
||
issue: Issue
|
||
onStatusChange: (issue: Issue, status: string) => void
|
||
statusBusy: boolean
|
||
onFocus: (issue: Issue, traceId?: string) => void
|
||
detectedIPByAddress: Map<string, DetectedIP>
|
||
}) => {
|
||
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)
|
||
try {
|
||
const response = await authFetch("/api/needle/classify", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ issue_id: issue.id }),
|
||
})
|
||
setNeedleResult(await response.json())
|
||
} catch (err) {
|
||
setNeedleResult({ error: err instanceof Error ? err.message : String(err) })
|
||
} finally {
|
||
setNeedleLoading(false)
|
||
}
|
||
}
|
||
|
||
const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
|
||
const tone = issueSeverityTone(issue)
|
||
const latestSample = issue.samples[issue.samples.length - 1]
|
||
const stacktrace = latestStacktrace(issue)
|
||
const hasMappedStacktrace = issue.samples.some((sample) => Boolean(sample.resolvedStacktrace))
|
||
const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus)
|
||
const correlate = strongestCorrelate(issue)
|
||
const clientLabels = [...new Set(issue.samples.map((sample) => sample.uaClass).filter((value): value is string => Boolean(value)))]
|
||
.filter((value) => value !== "browser" && value !== "unknown" && value !== "none")
|
||
.map(uaClassLabel)
|
||
const badActorSources = [...new Set(issue.samples.flatMap((sample) => sample.sourceIp ? detectedIPByAddress.get(sample.sourceIp)?.sources ?? [] : []))]
|
||
|
||
const viewTrace = (traceId: string) => onFocus(issue, traceId)
|
||
|
||
const metaParts = [`service=${issue.service}`]
|
||
if (contextSample?.httpMethod && contextSample?.httpRoute) {
|
||
metaParts.push(`route=${contextSample.httpMethod} ${contextSample.httpRoute}`)
|
||
} else if (contextSample?.httpRoute) {
|
||
metaParts.push(`route=${contextSample.httpRoute}`)
|
||
}
|
||
if (issue.affectedServices.length > 1) {
|
||
metaParts.push(`affects=${issue.affectedServices.length} services`)
|
||
}
|
||
|
||
return (
|
||
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
||
<div className="flex w-full flex-wrap items-start gap-4 px-5 pt-[18px] pb-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
aria-expanded={expanded}
|
||
aria-label={`${expanded ? "Collapse" : "Expand"} details for ${issue.title}`}
|
||
className="flex min-w-0 flex-1 flex-col gap-1.5 text-left"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
<span className={`inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||
{toneLabel[tone]}
|
||
</span>
|
||
{issue.regressedAt && issueStatusOf(issue) === "open" ? (
|
||
<span
|
||
title={`Was resolved, fired again ${formatRelativeTime(issue.regressedAt / 1e6)}`}
|
||
className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] bg-ds-warn-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-warn"
|
||
>
|
||
<RotateCcw className="h-3 w-3" aria-hidden="true" />
|
||
Regressed
|
||
</span>
|
||
) : null}
|
||
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||
{meta.label}
|
||
</span>
|
||
{clientLabels.slice(0, 2).map((label) => (
|
||
<span key={label} className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-accent/30 bg-ds-accent-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-accent">
|
||
<Bot className="h-3 w-3" aria-hidden="true" />
|
||
{label}
|
||
</span>
|
||
))}
|
||
{badActorSources.length > 0 && (
|
||
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-warn/30 bg-ds-warn-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-warn" title={`Detected by ${badActorSources.join(" and ")}`}>
|
||
<ShieldBan className="h-3 w-3" aria-hidden="true" />
|
||
Bad actor IP
|
||
</span>
|
||
)}
|
||
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} />
|
||
<span className="text-[12.5px] text-ds-text-3">
|
||
Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "}
|
||
{formatRelativeTime(issue.lastSeen / 1e6)}
|
||
</span>
|
||
</div>
|
||
<h3 className="font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text break-all">
|
||
{issue.title}
|
||
</h3>
|
||
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{metaParts.join(" · ")}</p>
|
||
</button>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{latestSample?.traceId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => viewTrace(latestSample.traceId!)}
|
||
className="inline-flex h-9 items-center rounded-[9px] bg-ds-accent px-3.5 text-[13px] font-semibold text-ds-on-accent"
|
||
>
|
||
View trace
|
||
</button>
|
||
)}
|
||
<StatusActions issue={issue} onStatusChange={onStatusChange} busy={statusBusy} />
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
aria-expanded={expanded}
|
||
aria-label={`${expanded ? "Collapse" : "Expand"} details for ${issue.title}`}
|
||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||
>
|
||
{expanded ? (
|
||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-5">
|
||
<IssueSparkline issue={issue} />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
|
||
<StatCell label="Occurrences" value={`×${issue.count}`} />
|
||
<StatCell label="Affected services" value={issue.affectedServices.length || 1} />
|
||
<StatCell label="First seen" value={formatRelativeTime(issue.firstSeen / 1e6)} />
|
||
<div className="flex flex-[1.4] flex-col gap-1">
|
||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Strongest correlate</span>
|
||
{correlate ? (
|
||
<span className="font-[family-name:var(--font-data)] text-[13px] font-medium text-ds-text">
|
||
{correlate.key}={correlate.value}{" "}
|
||
<span className="font-[family-name:var(--font-ui)] text-[12px] text-ds-text-3">
|
||
{Math.round(correlate.share * 100)}% of {correlate.total} occurrences
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="font-[family-name:var(--font-data)] text-[13px] font-medium text-ds-text-3">NO SIGNAL</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div className="flex flex-col gap-4 border-t border-ds-line bg-ds-bg px-5 py-4 text-[12.5px]">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-4 text-[11px] text-ds-text-3">
|
||
{issue.status === "resolved" && issue.resolvedAt ? (
|
||
<span>Resolved {formatRelativeTime(issue.resolvedAt / 1e6)}</span>
|
||
) : null}
|
||
{issue.affectedServices.length > 1 && (
|
||
<span>Affects: {issue.affectedServices.join(", ")}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{stacktrace && (
|
||
<div className="space-y-1.5">
|
||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||
{hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}
|
||
</p>
|
||
<StacktraceView stacktrace={stacktrace} />
|
||
</div>
|
||
)}
|
||
|
||
<BreadcrumbsView issue={issue} />
|
||
|
||
<div className="flex flex-wrap gap-4 text-[11px] text-ds-text-3">
|
||
{(contextSample?.userAgent || latestSample?.userAgent) && (
|
||
<span className="inline-flex max-w-[360px] items-center gap-1.5 truncate" title={contextSample?.userAgent || latestSample?.userAgent}>
|
||
<MonitorSmartphone className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||
{(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)}
|
||
</span>
|
||
)}
|
||
{(contextSample?.sourceIp || latestSample?.sourceIp) && (
|
||
<IpButton ip={(contextSample?.sourceIp || latestSample?.sourceIp)!} />
|
||
)}
|
||
{badActorSources.length > 0 && (
|
||
<span className="font-[family-name:var(--font-data)] text-ds-warn">detected_ip={badActorSources.join("+")}</span>
|
||
)}
|
||
{(contextSample?.release || latestSample?.release) && (
|
||
<span>release: {contextSample?.release || latestSample?.release}</span>
|
||
)}
|
||
{(contextSample?.environment || latestSample?.environment) && (
|
||
<span>env: {contextSample?.environment || latestSample?.environment}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleNeedleTriage()}
|
||
disabled={needleLoading}
|
||
title="Classify this issue using local Needle 3 model"
|
||
className="inline-flex h-8 items-center gap-2 rounded-[9px] border border-ds-line-strong px-3 text-[12px] font-medium text-ds-text-2 hover:text-ds-text disabled:opacity-50"
|
||
>
|
||
{needleLoading ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||
) : (
|
||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||
)}
|
||
{needleLoading ? "Needle 3 triaging…" : "Needle 3 triage"}
|
||
</button>
|
||
{needleResult && (
|
||
<div className="space-y-2 rounded-[12px] border border-ds-line bg-ds-surface-2 p-3 text-[12px]">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ds-line pb-2">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ds-text-3">Needle 3 action</span>
|
||
<span className="rounded-[6px] border border-ds-line-strong px-2 py-0.5 font-[family-name:var(--font-data)] text-ds-text">
|
||
{needleResult.action || needleResult.tool || "Processed"}
|
||
</span>
|
||
</span>
|
||
{needleResult.confidence !== undefined && (
|
||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||
confidence{" "}
|
||
<span className={needleResult.confidence >= 0.6 ? "text-ds-ok" : "text-ds-warn"}>
|
||
{(needleResult.confidence * 100).toFixed(0)}%
|
||
</span>
|
||
{needleResult.latency_ms ? ` (${needleResult.latency_ms}ms)` : ""}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{needleResult.reasoning && <p className="italic text-ds-text-2">“{needleResult.reasoning}”</p>}
|
||
{needleResult.arguments && Object.keys(needleResult.arguments).length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 font-[family-name:var(--font-data)] text-[11px]">
|
||
{Object.entries(needleResult.arguments).map(([k, v]) => (
|
||
<span key={k} className="rounded-[6px] border border-ds-line bg-ds-surface px-2 py-0.5 text-ds-text-2">
|
||
<span className="text-ds-text-3">{k}:</span> {String(v)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{needleResult.error && (
|
||
<p className="font-[family-name:var(--font-data)] text-[11px] text-ds-critical">{needleResult.error}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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})
|
||
</p>
|
||
<div className="space-y-1.5">
|
||
{[...issue.samples].reverse().map((sample, idx) => (
|
||
<div
|
||
key={`${sample.timestamp}-${idx}`}
|
||
className="flex flex-wrap items-center gap-3 rounded-[9px] border border-ds-line bg-ds-surface px-2.5 py-1.5"
|
||
>
|
||
<span className="shrink-0 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||
{new Date(sample.timestamp / 1e6).toLocaleTimeString([], {
|
||
hour12: false,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
})}
|
||
</span>
|
||
<SampleContextChips sample={sample} />
|
||
<span className="min-w-[160px] flex-1 break-all text-ds-text-2">{sample.message}</span>
|
||
{sample.url && (
|
||
<a
|
||
href={sample.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex max-w-[220px] shrink-0 items-center gap-1 truncate rounded-full border border-ds-accent/30 bg-ds-accent-bg px-2.5 py-0.5 text-[10.5px] font-bold text-ds-accent"
|
||
title={sample.url}
|
||
>
|
||
<ExternalLink className="h-2.5 w-2.5 shrink-0" aria-hidden="true" />
|
||
<span className="truncate">{sample.url.replace(/^https?:\/\//, "")}</span>
|
||
</a>
|
||
)}
|
||
{sample.traceId && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
viewTrace(sample.traceId!)
|
||
}}
|
||
className="flex shrink-0 items-center gap-1.5 rounded-full border border-ds-line-strong px-2.5 py-0.5 text-[10.5px] font-bold text-ds-text-2 hover:text-ds-text"
|
||
>
|
||
<Activity className="h-2.5 w-2.5" aria-hidden="true" />
|
||
trace:{sample.traceId.slice(0, 6)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
)
|
||
}
|
||
|
||
const ServiceDeviationPanel = () => {
|
||
const { uniqueServices, serviceHealth } = useObservabilityData()
|
||
|
||
const rows = useMemo(() => {
|
||
return uniqueServices
|
||
.map((name) => {
|
||
const health = serviceHealth[name]
|
||
const total = health?.totalSignals ?? 0
|
||
const errors = health?.errorCount ?? 0
|
||
return { name, total, errors, rate: total > 0 ? errors / total : 0 }
|
||
})
|
||
.sort((a, b) => b.errors - a.errors || b.rate - a.rate)
|
||
.slice(0, 8)
|
||
}, [uniqueServices, serviceHealth])
|
||
|
||
return (
|
||
<section className="rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||
<div className="mb-2 flex items-baseline gap-2">
|
||
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">Services by deviation</h2>
|
||
<span className="flex-grow" />
|
||
<span className="text-[11.5px] text-ds-text-3">{uniqueServices.length} total</span>
|
||
</div>
|
||
{rows.length === 0 ? (
|
||
<p className="py-3 text-[12px] italic text-ds-text-3">Listening for service signals…</p>
|
||
) : (
|
||
<ul className="flex flex-col">
|
||
{rows.map((row) => (
|
||
<li key={row.name} className="flex items-center gap-3 border-b border-ds-fill py-2.5 last:border-b-0">
|
||
<span className="flex-1 truncate font-[family-name:var(--font-data)] text-[12.5px] text-ds-text">
|
||
{row.name}
|
||
</span>
|
||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-ds-fill" aria-hidden="true">
|
||
<div
|
||
className={`h-full rounded-full ${row.errors > 0 ? "bg-ds-critical" : "bg-ds-line-strong"}`}
|
||
style={{ width: `${Math.max(row.rate * 100, row.errors > 0 ? 6 : 0)}%` }}
|
||
/>
|
||
</div>
|
||
<span
|
||
className={`w-16 shrink-0 text-right font-[family-name:var(--font-data)] text-[12.5px] ${
|
||
row.errors > 0 ? "text-ds-critical" : "text-ds-text-3"
|
||
}`}
|
||
>
|
||
{row.errors > 0 ? `${(row.rate * 100).toFixed(1)}%` : "0%"}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const IssueStatusPanel = ({ counts }: { counts: Record<StatusFilter, number> }) => {
|
||
const total = counts.all || 1
|
||
const segments: { key: StatusFilter; tone: SeverityTone }[] = [
|
||
{ key: "open", tone: "critical" },
|
||
{ key: "resolved", tone: "ok" },
|
||
{ key: "ignored", tone: "muted" },
|
||
]
|
||
return (
|
||
<section className="rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||
<h2 className="mb-3 text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">Issue status</h2>
|
||
<div className="mb-3 flex h-2 overflow-hidden rounded-full bg-ds-fill" aria-hidden="true">
|
||
{segments.map((seg) => (
|
||
<span
|
||
key={seg.key}
|
||
className={toneDot[seg.tone]}
|
||
style={{ width: `${(counts[seg.key] / total) * 100}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
<ul className="flex flex-col gap-2">
|
||
{segments.map((seg) => (
|
||
<li key={seg.key} className="flex items-center gap-2 text-[13px]">
|
||
<span className={`h-2 w-2 rounded-full ${toneDot[seg.tone]}`} aria-hidden="true" />
|
||
<span className="flex-1 capitalize text-ds-text-2">{seg.key}</span>
|
||
<span className="font-[family-name:var(--font-data)] text-ds-text">{counts[seg.key]}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
interface IssuesVisualizerProps {
|
||
issues: Issue[]
|
||
loading: boolean
|
||
error: string | null
|
||
refresh: () => void
|
||
onStatusChange: (issue: Issue, status: string) => void
|
||
statusBusy: boolean
|
||
}
|
||
|
||
export const IssuesVisualizer = ({
|
||
issues,
|
||
loading,
|
||
error,
|
||
refresh,
|
||
onStatusChange,
|
||
statusBusy,
|
||
}: IssuesVisualizerProps) => {
|
||
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
||
useObservabilityData()
|
||
// /issues/<issueId>[/trace/<traceId>] - the focused issue and trace live in the URL.
|
||
const { segments, go } = useSubRoute()
|
||
const focus = segments[0] ? { issueId: segments[0], traceId: segments[1] === "trace" ? segments[2] : undefined } : null
|
||
const focusIssue = (issue: Issue, traceId?: string) => go(traceId ? [issue.id, "trace", traceId] : [issue.id])
|
||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||
const [groupBy, setGroupBy] = useState<GroupBy>("none")
|
||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
||
const [detectorTestLoading, setDetectorTestLoading] = useState(false)
|
||
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
|
||
// Client-class filters: an issue matches "client" when any sample came from
|
||
// one of these classes, and is hidden by "not client" when every classified
|
||
// sample did (a mixed human + bot issue stays visible).
|
||
const [clientInclude, setClientInclude] = useState<Set<string>>(new Set())
|
||
const [clientExclude, setClientExclude] = useState<Set<string>>(new Set())
|
||
const [showSpamIPs, setShowSpamIPs] = useState(false)
|
||
const [spamIPs, setSpamIPs] = useState<HoneypotIP[]>([])
|
||
const [blockerStatus, setBlockerStatus] = useState<BlockerStatus | null>(null)
|
||
const [spamIPsLoading, setSpamIPsLoading] = useState(false)
|
||
const [spamIPsError, setSpamIPsError] = useState<string | null>(null)
|
||
|
||
const loadSpamIPs = async () => {
|
||
setSpamIPsLoading(true)
|
||
setSpamIPsError(null)
|
||
try {
|
||
const response = await authFetch("/api/honeypot/ips")
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||
setSpamIPs((await response.json()) as HoneypotIP[])
|
||
// The blocker route only exists when the blocker is enabled; its absence is not an error.
|
||
const blocker = await authFetch("/api/blocker/status").catch(() => null)
|
||
if (blocker?.ok) setBlockerStatus((await blocker.json()) as BlockerStatus)
|
||
} catch (err) {
|
||
setSpamIPsError(err instanceof Error ? err.message : String(err))
|
||
} finally {
|
||
setSpamIPsLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadSpamIPs()
|
||
// The detector set is loaded once when the Issues view opens.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const detectedIPs = useMemo(() => {
|
||
const byIP = new Map<string, DetectedIP>()
|
||
for (const entry of spamIPs) byIP.set(entry.ip, { ip: entry.ip, sources: ["honeypot"], count: entry.count })
|
||
for (const ip of blockerStatus?.candidates ?? []) {
|
||
const known = byIP.get(ip)
|
||
if (known) known.sources.push("blocker")
|
||
else byIP.set(ip, { ip, sources: ["blocker"] })
|
||
}
|
||
return Array.from(byIP.values())
|
||
}, [spamIPs, blockerStatus])
|
||
const spamIPSet = useMemo(() => new Set(detectedIPs.map((entry) => entry.ip)), [detectedIPs])
|
||
const detectedIPByAddress = useMemo(() => new Map(detectedIPs.map((entry) => [entry.ip, entry])), [detectedIPs])
|
||
|
||
const crawlersHidden = CRAWLER_CLASSES.every((c) => clientExclude.has(c))
|
||
const toggleCrawlers = () => {
|
||
setClientExclude((prev) => {
|
||
const next = new Set(prev)
|
||
if (CRAWLER_CLASSES.every((c) => prev.has(c))) CRAWLER_CLASSES.forEach((c) => next.delete(c))
|
||
else CRAWLER_CLASSES.forEach((c) => next.add(c))
|
||
return next
|
||
})
|
||
setClientInclude((prev) => {
|
||
const next = new Set(prev)
|
||
CRAWLER_CLASSES.forEach((c) => next.delete(c))
|
||
return next
|
||
})
|
||
}
|
||
|
||
const statusCounts = useMemo(() => {
|
||
const counts: Record<StatusFilter, number> = { all: issues.length, open: 0, resolved: 0, ignored: 0 }
|
||
issues.forEach((issue) => {
|
||
counts[issueStatusOf(issue)] += 1
|
||
})
|
||
return counts
|
||
}, [issues])
|
||
|
||
const filteredIssues = useMemo(() => {
|
||
return issues.filter((issue) => {
|
||
if (statusFilter !== "all" && issueStatusOf(issue) !== statusFilter) return false
|
||
if (kindFilter !== "all" && issue.kind !== kindFilter) return false
|
||
if (
|
||
excludeSpamIPs &&
|
||
issue.samples.some(
|
||
(sample) =>
|
||
(sample.httpStatus ?? 0) >= 400 &&
|
||
Boolean(sample.sourceIp) &&
|
||
spamIPSet.has(sample.sourceIp!),
|
||
)
|
||
) {
|
||
return false
|
||
}
|
||
if (clientInclude.size > 0 || clientExclude.size > 0) {
|
||
const classes = issue.samples.map((s) => s.uaClass).filter((c): c is string => Boolean(c))
|
||
if (clientInclude.size > 0 && !classes.some((c) => clientInclude.has(c))) return false
|
||
if (clientExclude.size > 0 && classes.length > 0 && classes.every((c) => clientExclude.has(c))) return false
|
||
}
|
||
if (selectedServices.size > 0) {
|
||
const matches =
|
||
selectedServices.has(issue.service) ||
|
||
issue.affectedServices.some((s) => selectedServices.has(s))
|
||
if (!matches) return false
|
||
}
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase()
|
||
const matches =
|
||
issue.title.toLowerCase().includes(q) || issue.service.toLowerCase().includes(q)
|
||
if (!matches) return false
|
||
}
|
||
return true
|
||
})
|
||
}, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet, clientInclude, clientExclude])
|
||
|
||
const { primaryIssues, secondaryIssues } = useMemo(() => {
|
||
const sorted = [...filteredIssues].sort((a, b) => b.count - a.count)
|
||
if (statusFilter !== "all") return { primaryIssues: sorted, secondaryIssues: [] as Issue[] }
|
||
return {
|
||
primaryIssues: sorted.filter((i) => issueStatusOf(i) === "open"),
|
||
secondaryIssues: sorted.filter((i) => issueStatusOf(i) !== "open"),
|
||
}
|
||
}, [filteredIssues, statusFilter])
|
||
|
||
const groupedPrimaryIssues = useMemo(() => {
|
||
if (groupBy === "none") return [["", primaryIssues] as const]
|
||
const groups = new Map<string, Issue[]>()
|
||
for (const issue of primaryIssues) {
|
||
const name = issue.service || "unknown"
|
||
const group = groups.get(name) ?? []
|
||
group.push(issue)
|
||
groups.set(name, group)
|
||
}
|
||
return [...groups.entries()].sort((a, b) => b[1].reduce((n, issue) => n + issue.count, 0) - a[1].reduce((n, issue) => n + issue.count, 0))
|
||
}, [groupBy, primaryIssues])
|
||
|
||
const [showSecondary, setShowSecondary] = useState(false)
|
||
|
||
const runDetectorTest = async () => {
|
||
setDetectorTestLoading(true)
|
||
setDetectorTest(null)
|
||
try {
|
||
const response = await authFetch("/api/ml/test-spike", { method: "POST" })
|
||
const data = (await response.json()) as DetectorTestResult
|
||
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
|
||
setDetectorTest(data)
|
||
} catch (err) {
|
||
setDetectorTest({ error: err instanceof Error ? err.message : String(err) })
|
||
} finally {
|
||
setDetectorTestLoading(false)
|
||
}
|
||
}
|
||
|
||
const activeFilterCount =
|
||
selectedServices.size +
|
||
clientInclude.size +
|
||
clientExclude.size +
|
||
(statusFilter !== "all" ? 1 : 0) +
|
||
(excludeSpamIPs ? 1 : 0) +
|
||
(searchQuery ? 1 : 0)
|
||
|
||
const chips: FilterChip[] = [
|
||
...Array.from(selectedServices).map((service) => ({
|
||
id: `service-${service}`,
|
||
filterKey: "service",
|
||
value: service,
|
||
tone: "neutral" as const,
|
||
onRemove: () => toggleService(service),
|
||
})),
|
||
...Array.from(clientInclude).map((c) => ({
|
||
id: `client-${c}`,
|
||
filterKey: "client",
|
||
value: uaClassLabel(c),
|
||
tone: "neutral" as const,
|
||
onRemove: () => setClientInclude((prev) => without(prev, c)),
|
||
})),
|
||
...Array.from(clientExclude).map((c) => ({
|
||
id: `not-client-${c}`,
|
||
filterKey: "not client",
|
||
value: uaClassLabel(c),
|
||
tone: "neutral" as const,
|
||
onRemove: () => setClientExclude((prev) => without(prev, c)),
|
||
})),
|
||
...(statusFilter !== "all"
|
||
? [
|
||
{
|
||
id: "status",
|
||
filterKey: "status",
|
||
value: statusFilter,
|
||
tone: statusFilter === "resolved" ? ("ok" as const) : ("neutral" as const),
|
||
onRemove: () => setStatusFilter("all"),
|
||
},
|
||
]
|
||
: []),
|
||
...(excludeSpamIPs
|
||
? [
|
||
{
|
||
id: "spam-ips",
|
||
filterKey: "spam-ips",
|
||
value: "hidden",
|
||
tone: "neutral" as const,
|
||
onRemove: () => setExcludeSpamIPs(false),
|
||
},
|
||
]
|
||
: []),
|
||
...(searchQuery
|
||
? [
|
||
{
|
||
id: "query",
|
||
filterKey: "query",
|
||
value: searchQuery,
|
||
tone: "neutral" as const,
|
||
onRemove: () => setSearchQuery(""),
|
||
},
|
||
]
|
||
: []),
|
||
]
|
||
|
||
const addFilterOptions = [
|
||
...uniqueServices.filter((s) => !selectedServices.has(s)).map((s) => ({ value: s, label: s })),
|
||
...UA_CLASSES.filter((c) => !clientInclude.has(c.value)).map((c) => ({
|
||
value: `client:${c.value}`,
|
||
label: `client: ${c.label}`,
|
||
})),
|
||
...UA_CLASSES.filter((c) => !clientExclude.has(c.value)).map((c) => ({
|
||
value: `not-client:${c.value}`,
|
||
label: `hide client: ${c.label}`,
|
||
})),
|
||
]
|
||
|
||
const addFilter = (value: string) => {
|
||
if (value.startsWith("client:")) {
|
||
const c = value.slice("client:".length)
|
||
setClientInclude((prev) => new Set(prev).add(c))
|
||
setClientExclude((prev) => without(prev, c))
|
||
} else if (value.startsWith("not-client:")) {
|
||
const c = value.slice("not-client:".length)
|
||
setClientExclude((prev) => new Set(prev).add(c))
|
||
setClientInclude((prev) => without(prev, c))
|
||
} else {
|
||
toggleService(value)
|
||
}
|
||
}
|
||
|
||
const clearAll = () => {
|
||
resetFilters()
|
||
setSearchQuery("")
|
||
setStatusFilter("all")
|
||
setGroupBy("none")
|
||
setExcludeSpamIPs(false)
|
||
setClientInclude(new Set())
|
||
setClientExclude(new Set())
|
||
}
|
||
|
||
if (loading && issues.length === 0) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-20 text-center text-sm text-ds-text-3">
|
||
<AlertOctagon className="mx-auto mb-4 h-10 w-10 opacity-30" aria-hidden="true" />
|
||
Loading issues…
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const focusedIssue = focus ? issues.find((i) => i.id === focus.issueId) : undefined
|
||
if (focus && focusedIssue) {
|
||
return (
|
||
<IssueOccurrences
|
||
key={focus.issueId}
|
||
issue={focusedIssue}
|
||
traceId={focus.traceId}
|
||
onTraceChange={(traceId) => go(traceId ? [focus.issueId, "trace", traceId] : [focus.issueId], { replace: true })}
|
||
onBack={() => go([])}
|
||
/>
|
||
)
|
||
}
|
||
if (focus) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||
<p className="text-[14px] font-medium text-ds-text">Issue not found</p>
|
||
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{focus.issueId}</p>
|
||
<p className="max-w-sm text-[12.5px] text-ds-text-3">It may have been evicted, or the link is from another environment.</p>
|
||
<button type="button" onClick={() => go([])} className="inline-flex h-9 items-center rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:text-ds-text">All issues</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
<FilterBar
|
||
scope={{
|
||
label: "Signal kind",
|
||
options: scopeOptions,
|
||
value: kindFilter,
|
||
onChange: (v) => setKindFilter(v as KindFilter),
|
||
}}
|
||
chips={chips}
|
||
addFilterOptions={addFilterOptions}
|
||
onAddFilter={addFilter}
|
||
matchText={`Matching ${filteredIssues.length} of ${issues.length} issues · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`}
|
||
onClearAll={activeFilterCount > 0 ? clearAll : undefined}
|
||
/>
|
||
|
||
<div className="flex gap-5">
|
||
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<h1 className="font-[family-name:var(--font-display)] text-[22px] font-bold tracking-[-0.015em] text-ds-text">
|
||
{statusFilter === "all" ? "Needs attention" : `${statusTabs.find((s) => s.value === statusFilter)?.label} issues`}
|
||
</h1>
|
||
<span className="inline-flex h-[22px] items-center rounded-[6px] bg-ds-critical-bg px-2 text-[11.5px] font-semibold text-ds-critical">
|
||
{statusFilter === "all" ? `${primaryIssues.length} open` : filteredIssues.length}
|
||
</span>
|
||
<span className="flex-grow" />
|
||
<div className="flex items-center gap-1 rounded-[9px] border border-ds-line bg-ds-surface p-0.5">
|
||
{statusTabs.map((t) => (
|
||
<button
|
||
key={t.value}
|
||
type="button"
|
||
onClick={() => setStatusFilter(t.value)}
|
||
aria-pressed={statusFilter === t.value}
|
||
className={`rounded-[6px] px-2.5 py-1 text-[11.5px] font-semibold transition-colors ${
|
||
statusFilter === t.value ? "bg-ds-fill text-ds-text" : "text-ds-text-3 hover:text-ds-text-2"
|
||
}`}
|
||
>
|
||
{t.label}
|
||
<span className="ml-1 opacity-60">{statusCounts[t.value]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-1 rounded-[9px] border border-ds-line bg-ds-surface p-0.5" aria-label="Group issues">
|
||
{(["none", "service"] as GroupBy[]).map((value) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => setGroupBy(value)}
|
||
aria-pressed={groupBy === value}
|
||
className={`rounded-[6px] px-2.5 py-1 text-[11.5px] font-semibold transition-colors ${
|
||
groupBy === value ? "bg-ds-fill text-ds-text" : "text-ds-text-3 hover:text-ds-text-2"
|
||
}`}
|
||
>
|
||
{value === "none" ? "Flat" : "By service"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (!excludeSpamIPs && spamIPs.length === 0) void loadSpamIPs()
|
||
setExcludeSpamIPs((v) => !v)
|
||
}}
|
||
aria-pressed={excludeSpamIPs}
|
||
title="Hide HTTP errors whose recorded source IP is on the honeypot list or the blocker's candidate list"
|
||
className={`inline-flex h-9 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] font-medium transition-colors ${
|
||
excludeSpamIPs
|
||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||
: "border-ds-line-strong text-ds-text-3 hover:text-ds-text-2"
|
||
}`}
|
||
>
|
||
<ShieldBan className="h-3.5 w-3.5" aria-hidden="true" />
|
||
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleCrawlers}
|
||
aria-pressed={crawlersHidden}
|
||
title="Hide issues whose requests came from AI crawlers, AI agents, search/SEO crawlers, scan tools or other self-declared bots (by User-Agent)"
|
||
className={`inline-flex h-9 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] font-medium transition-colors ${
|
||
crawlersHidden
|
||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||
: "border-ds-line-strong text-ds-text-3 hover:text-ds-text-2"
|
||
}`}
|
||
>
|
||
<Bot className="h-3.5 w-3.5" aria-hidden="true" />
|
||
{crawlersHidden ? "Crawlers hidden" : "Hide crawlers"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setNeedleModalOpen(true)}
|
||
title="Open interactive Needle 3 telemetry classifier"
|
||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
|
||
>
|
||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||
Test Needle 3
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void runDetectorTest()}
|
||
disabled={detectorTestLoading}
|
||
title="Run a non-persistent synthetic spike through the ML feature and label pipeline"
|
||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text disabled:cursor-wait disabled:opacity-60"
|
||
>
|
||
{detectorTestLoading ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||
) : (
|
||
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
|
||
)}
|
||
Test spike detector
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => refresh()}
|
||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} aria-hidden="true" />
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-[12px] text-ds-critical">Failed to load: {error}</p>
|
||
)}
|
||
|
||
{detectorTest && (
|
||
<div
|
||
className={`flex flex-wrap items-center justify-between gap-3 rounded-[12px] border px-3 py-2 text-[12.5px] ${
|
||
detectorTest.passed
|
||
? "border-ds-ok/30 text-ds-text"
|
||
: "border-ds-critical/30 bg-ds-critical-bg text-ds-text"
|
||
}`}
|
||
>
|
||
<div>
|
||
<p className={`font-semibold ${detectorTest.passed ? "text-ds-ok" : "text-ds-critical"}`}>
|
||
{detectorTest.passed ? "Spike detector verified" : "Spike detector test failed"}
|
||
</p>
|
||
<p className="mt-0.5 text-[11px] text-ds-text-3">{detectorTest.error ?? detectorTest.scenario}</p>
|
||
</div>
|
||
{detectorTest.row && (
|
||
<div className="flex flex-wrap gap-x-3 gap-y-1 font-[family-name:var(--font-data)] text-[11px] text-ds-text-2">
|
||
<span>baseline {(detectorTest.row.baselineErrorRate * 100).toFixed(1)}%</span>
|
||
<span>future errors {detectorTest.row.futureErrorCount}</span>
|
||
<span>future {(detectorTest.row.futureErrorRate * 100).toFixed(1)}%</span>
|
||
<span>{detectorTest.row.labelKnown && detectorTest.row.label ? "label: spike" : "label: not spike"}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showSpamIPs && (
|
||
<div className="rounded-[12px] border border-ds-warn/30 bg-ds-warn-bg p-3 text-[12.5px] text-ds-text-2">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-bold text-ds-warn">Detected scanner IPs</div>
|
||
<div className="text-[11px] text-ds-text-3">
|
||
Honeypot-promoted IPs (ingress tarpit) and blocker candidates
|
||
{blockerStatus ? (blockerStatus.dryRun ? " (blocker in dry-run: nothing is blocked)" : " (blocker enforcing)") : ""}.
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSpamIPs(false)}
|
||
aria-label="Close spam IP list"
|
||
className="rounded p-1 text-ds-text-3 hover:text-ds-text"
|
||
>
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{spamIPsLoading && <div className="text-ds-text-3">Loading…</div>}
|
||
{spamIPsError && <div className="text-ds-critical">Failed to load: {spamIPsError}</div>}
|
||
{!spamIPsLoading && !spamIPsError && detectedIPs.length === 0 && (
|
||
<div className="text-ds-text-3">No detected IPs.</div>
|
||
)}
|
||
{detectedIPs.length > 0 && (
|
||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||
{detectedIPs.map((entry) => (
|
||
<div key={entry.ip} className="flex items-center justify-between gap-2 rounded-[8px] bg-ds-bg px-2.5 py-1.5 font-[family-name:var(--font-data)]">
|
||
<IpButton ip={entry.ip} />
|
||
<span className="shrink-0 whitespace-nowrap text-[10.5px] text-ds-warn">
|
||
{entry.sources.join(" + ")}
|
||
{entry.count !== undefined ? ` ×${entry.count}` : ""}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!showSpamIPs && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowSpamIPs(true)
|
||
if (spamIPs.length === 0) void loadSpamIPs()
|
||
}}
|
||
className="self-start text-[11.5px] text-ds-text-3 hover:text-ds-text-2"
|
||
>
|
||
View detected scanner IPs ({detectedIPs.length})
|
||
</button>
|
||
)}
|
||
|
||
{primaryIssues.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||
<CheckCircle2 className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
|
||
<p className="text-[14px] font-medium text-ds-text">
|
||
{issues.length === 0 ? "No issues detected" : "Nothing matches your filters"}
|
||
</p>
|
||
<p className="max-w-sm text-[12.5px] text-ds-text-3">
|
||
{issues.length === 0
|
||
? "Error-status spans and error/fatal-severity logs will show up here as they arrive."
|
||
: "Try widening the scope or clearing a filter."}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-5">
|
||
{groupedPrimaryIssues.map(([service, group]) => (
|
||
<section key={service || "all"} className="flex flex-col gap-3">
|
||
{groupBy === "service" && (
|
||
<div className="flex items-baseline gap-2 px-1">
|
||
<h2 className="font-[family-name:var(--font-display)] text-[15px] font-bold text-ds-text">{service}</h2>
|
||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{group.length} issue{group.length === 1 ? "" : "s"} · {group.reduce((n, issue) => n + issue.count, 0)} occurrences</span>
|
||
</div>
|
||
)}
|
||
{group.map((issue) => (
|
||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} detectedIPByAddress={detectedIPByAddress} />
|
||
))}
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{secondaryIssues.length > 0 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSecondary((v) => !v)}
|
||
className="flex items-center gap-2.5 rounded-[12px] border border-dashed border-ds-line px-4 py-3 text-left text-[13px] text-ds-text-3 hover:text-ds-text-2"
|
||
>
|
||
{showSecondary ? (
|
||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||
)}
|
||
{secondaryIssues.length} resolved or ignored
|
||
</button>
|
||
{showSecondary && (
|
||
<div className="flex flex-col gap-3">
|
||
{secondaryIssues.map((issue) => (
|
||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} detectedIPByAddress={detectedIPByAddress} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<aside className="flex w-[340px] shrink-0 flex-col gap-3.5">
|
||
<ServiceDeviationPanel />
|
||
<IssueStatusPanel counts={statusCounts} />
|
||
</aside>
|
||
</div>
|
||
|
||
<NeedleTestModal isOpen={needleModalOpen} onClose={() => setNeedleModalOpen(false)} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default IssuesVisualizer
|