Restore IssuesVisualizer redesign reverted by merge 67ce419 and re-apply
Needle triage/test additions. Migrate all remaining screens off the MD3
palette, raw Tailwind colors, gradients and Material Symbols. Remove the
legacy theme block and fonts, drop the duplicate tab strip, rebuild
TelemetryFilters on FilterBar, and document status in AGENTS.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1038 lines
41 KiB
TypeScript
1038 lines
41 KiB
TypeScript
import { useMemo, useState, type ReactNode } from "react"
|
||
import {
|
||
Activity,
|
||
AlertOctagon,
|
||
Bug,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Clock,
|
||
EyeOff,
|
||
ExternalLink,
|
||
Globe,
|
||
MonitorSmartphone,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
ScrollText,
|
||
ShieldBan,
|
||
Sparkles,
|
||
Loader2,
|
||
X,
|
||
} from "lucide-react"
|
||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||
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 { NeedleTestModal } from "./NeedleTestModal"
|
||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||
import type { Issue, IssueSample } from "../lib/types"
|
||
|
||
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"
|
||
type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
|
||
|
||
interface HoneypotIP {
|
||
ip: string
|
||
count: number
|
||
first_seen: string
|
||
last_seen: string
|
||
promoted_until: string
|
||
}
|
||
|
||
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",
|
||
}
|
||
|
||
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",
|
||
}
|
||
|
||
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. */
|
||
const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||
const { points, 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 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`
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
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,
|
||
}: {
|
||
issue: Issue
|
||
onStatusChange: (issue: Issue, status: string) => void
|
||
statusBusy: boolean
|
||
}) => {
|
||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||
const [expanded, setExpanded] = useState(false)
|
||
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
||
const [needleLoading, setNeedleLoading] = useState(false)
|
||
|
||
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 viewTrace = (traceId: string, spanId?: string) => {
|
||
setActiveTraceId(traceId)
|
||
if (spanId) setActiveSpanId(spanId)
|
||
setActiveTab("tab1")
|
||
}
|
||
|
||
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>
|
||
<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>
|
||
<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!, latestSample.spanId)}
|
||
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?.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">
|
||
<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!, sample.spanId)
|
||
}}
|
||
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()
|
||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
||
const [detectorTestLoading, setDetectorTestLoading] = useState(false)
|
||
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
|
||
const [showSpamIPs, setShowSpamIPs] = useState(false)
|
||
const [spamIPs, setSpamIPs] = useState<HoneypotIP[]>([])
|
||
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[])
|
||
} catch (err) {
|
||
setSpamIPsError(err instanceof Error ? err.message : String(err))
|
||
} finally {
|
||
setSpamIPsLoading(false)
|
||
}
|
||
}
|
||
|
||
const spamIPSet = useMemo(() => new Set(spamIPs.map((entry) => entry.ip)), [spamIPs])
|
||
|
||
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 (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])
|
||
|
||
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 [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 + (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),
|
||
})),
|
||
...(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 }))
|
||
|
||
const clearAll = () => {
|
||
resetFilters()
|
||
setSearchQuery("")
|
||
setStatusFilter("all")
|
||
setExcludeSpamIPs(false)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
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={(v) => toggleService(v)}
|
||
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>
|
||
<button
|
||
type="button"
|
||
onClick={() => setExcludeSpamIPs((v) => !v)}
|
||
aria-pressed={excludeSpamIPs}
|
||
title="Hide HTTP errors whose recorded source IP is on the honeypot 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={() => 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">Promoted honeypot IPs</div>
|
||
<div className="text-[11px] text-ds-text-3">Used by the ingress tarpit synchronizer.</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 && spamIPs.length === 0 && (
|
||
<div className="text-ds-text-3">No promoted IPs.</div>
|
||
)}
|
||
{spamIPs.length > 0 && (
|
||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||
{spamIPs.map((entry) => (
|
||
<div key={entry.ip} className="flex items-center justify-between rounded-[8px] bg-ds-bg px-2.5 py-1.5 font-[family-name:var(--font-data)]">
|
||
<span className="text-ds-text">{entry.ip}</span>
|
||
<span className="text-[10.5px] text-ds-warn">×{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 promoted spam IPs ({spamIPs.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-3">
|
||
{primaryIssues.map((issue) => (
|
||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
||
))}
|
||
</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} />
|
||
))}
|
||
</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
|