more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

+601
View File
@@ -0,0 +1,601 @@
import { useMemo, useState } from "react"
import {
Activity,
AlertOctagon,
Bug,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
EyeOff,
ExternalLink,
Globe,
MonitorSmartphone,
RefreshCw,
RotateCcw,
ScrollText,
ShieldBan,
X,
} from "lucide-react"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { authFetch } from "../lib/auth"
import { formatRelativeTime } from "../lib/timeUtils"
import { getServicePalette } from "../lib/otel-utils"
import {
issueStatusMeta,
issueStatusOf,
latestBreadcrumbs,
latestStacktrace,
sampleContext,
trendPoints,
} from "../lib/issue-utils"
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
}
const statusFilters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "open", label: "Open" },
{ value: "resolved", label: "Resolved" },
{ value: "ignored", label: "Ignored" },
]
const kindFilters: { value: KindFilter; label: string; Icon: typeof Activity }[] = [
{ value: "all", label: "All kinds", Icon: Bug },
{ value: "span_error", label: "Traces", Icon: Activity },
{ value: "log_error", label: "Logs", Icon: ScrollText },
{ value: "frontend_error", label: "Frontend", Icon: Globe },
]
// Lightweight inline-SVG sparkline of the issue's hourly occurrence trend.
// Deliberately not recharts: issue lists can hold dozens of cards and each
// recharts instance carries meaningful render cost.
const TrendSparkline = ({ issue }: { issue: Issue }) => {
const points = trendPoints(issue, 24)
const max = Math.max(...points.map((p) => p.count), 1)
const width = 96
const height = 24
const step = width / (points.length - 1)
const path = points
.map((p, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - (p.count / max) * (height - 2) - 1).toFixed(1)}`)
.join(" ")
const gradientId = `sparkFill-${issue.id}`
return (
<span
className="inline-flex shrink-0 flex-col items-end gap-0.5"
title="Occurrences per hour (last 24h)"
>
<svg width={width} height={height} className="overflow-visible">
<path d={`${path} L${width},${height} L0,${height} Z`} fill={`url(#${gradientId})`} opacity="0.35" />
<path d={path} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" className="text-rose-400/80" />
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" className="text-rose-400" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" className="text-rose-400" />
</linearGradient>
</defs>
</svg>
</span>
)
}
const StatusBadge = ({ issue }: { issue: Issue }) => {
const status = issueStatusOf(issue)
const meta = issueStatusMeta[status]
return (
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${meta.className}`}>
{meta.label}
</span>
)
}
const StacktraceView = ({ stacktrace }: { stacktrace: string }) => (
<pre className="max-h-64 overflow-auto rounded-xl border border-white/5 bg-black/60 p-3 font-mono text-[10px] leading-relaxed text-slate-300 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-[10px] font-bold uppercase tracking-widest text-slate-500">
<Clock className="h-3 w-3" />
Breadcrumbs before error
</p>
<div className="max-h-48 space-y-1 overflow-auto rounded-xl border border-white/5 bg-black/40 p-3">
{crumbs.map((crumb, idx) => (
<div key={`${crumb.t}-${idx}`} className="flex items-baseline gap-2 font-mono text-[10px]">
<span className="shrink-0 text-slate-600">
{new Date(crumb.t).toLocaleTimeString([], { hour12: false })}
</span>
<span className="shrink-0 rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-sky-400/80">
{crumb.category}
</span>
<span className="min-w-0 break-all text-slate-300">{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-amber-400/20 bg-amber-400/10 px-2 py-0.5 font-mono text-[10px] font-bold text-amber-300">
{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}
disabled={busy}
onClick={(e) => {
e.stopPropagation()
onStatusChange(issue, to)
}}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-all hover:border-white/20 hover:text-white disabled:opacity-50"
>
<Icon className="h-3 w-3" />
{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 meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
const isFrontend = issue.kind === "frontend_error"
const badgeGradient = isFrontend
? "from-violet-500 via-sky-500 to-cyan-400"
: "from-rose-500 via-red-500 to-orange-400"
const palette = getServicePalette(issue.service)
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 viewTrace = (traceId: string, spanId?: string) => {
setActiveTraceId(traceId)
if (spanId) setActiveSpanId(spanId)
setActiveTab("tab1")
}
return (
<div className="group relative flex flex-col overflow-hidden rounded-xl border border-rose-500/10 bg-slate-950/40 transition-all hover:border-rose-500/25 hover:bg-slate-900/50">
<div className={`absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b ${badgeGradient} opacity-50 transition-opacity group-hover:opacity-100`} />
<div
onClick={() => setExpanded((prev) => !prev)}
className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4"
>
<span className={`inline-flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r ${badgeGradient} text-slate-950`}>
<meta.Icon className="h-3 w-3" />
{meta.label}
</span>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${palette.bg} ${palette.text} ${palette.border} border`}>
{issue.service}
</span>
<span className="flex-1 min-w-[240px] break-all font-mono text-[12px] text-slate-200">
{issue.title}
</span>
<TrendSparkline issue={issue} />
<span className="shrink-0 rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold text-rose-300">
×{issue.count}
</span>
<StatusBadge issue={issue} />
<span className="shrink-0 text-[10px] text-slate-500 whitespace-nowrap">
last seen {formatRelativeTime(issue.lastSeen / 1e6)}
</span>
{expanded ? <ChevronUp className="h-3 w-3 text-slate-500" /> : <ChevronDown className="h-3 w-3 text-slate-500" />}
</div>
{expanded && (
<div className="space-y-4 border-t border-white/5 bg-black/40 p-4 text-[12px]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4 text-[10px] text-slate-500">
<span>First seen {formatRelativeTime(issue.firstSeen / 1e6)}</span>
{issue.affectedServices.length > 1 && (
<span>Affects: {issue.affectedServices.join(", ")}</span>
)}
{issue.status === "resolved" && issue.resolvedAt ? (
<span>Resolved {formatRelativeTime(issue.resolvedAt / 1e6)}</span>
) : null}
</div>
<StatusActions issue={issue} onStatusChange={onStatusChange} busy={statusBusy} />
</div>
{stacktrace && (
<div className="space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
{hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}
</p>
<StacktraceView stacktrace={stacktrace} />
</div>
)}
<BreadcrumbsView issue={issue} />
<div className="flex flex-wrap gap-4 text-[10px] text-slate-500">
{(contextSample?.userAgent || latestSample?.userAgent) && (
<span className="inline-flex items-center gap-1.5 max-w-[360px] truncate" title={contextSample?.userAgent || latestSample?.userAgent}>
<MonitorSmartphone className="h-3 w-3 shrink-0" />
{(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">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
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-lg border border-white/5 bg-white/5 px-2.5 py-1.5"
>
<span className="shrink-0 text-slate-500 font-mono text-[10px]">
{new Date(sample.timestamp / 1e6).toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
<SampleContextChips sample={sample} />
<span className="flex-1 min-w-[160px] break-all text-slate-300">{sample.message}</span>
{sample.url && (
<a
href={sample.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500/10 border border-violet-400/20 px-2.5 py-0.5 text-[10px] font-bold text-violet-300 hover:bg-violet-500/20 hover:border-violet-400/30 transition-all max-w-[220px] truncate"
title={sample.url}
>
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{sample.url.replace(/^https?:\/\//, "")}</span>
</a>
)}
{sample.traceId && (
<button
onClick={(e) => {
e.stopPropagation()
viewTrace(sample.traceId!, sample.spanId)
}}
className="group/trace flex shrink-0 items-center gap-1.5 rounded-full bg-sky-500/10 border border-sky-400/20 px-2.5 py-0.5 text-[10px] font-bold text-sky-400 hover:bg-sky-500/20 hover:border-sky-400/30 transition-all"
>
<Activity className="h-2.5 w-2.5 group-hover/trace:animate-pulse" />
TRACE:{sample.traceId.slice(0, 6)}
</button>
)}
</div>
))}
</div>
</div>
{latestSample?.traceId && (
<div className="mt-1 pt-4 border-t border-white/5 flex justify-end">
<button
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
className="inline-flex items-center gap-2 rounded-xl bg-sky-500 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-950 transition-all hover:bg-sky-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-sky-500/20"
>
<Activity className="h-3 w-3" />
View Latest Trace
</button>
</div>
)}
</div>
)}
</div>
)
}
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, searchQuery } = useObservabilityData()
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
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 = { 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])
if (loading && issues.length === 0) {
return (
<div className="py-12 text-center text-sm text-slate-400 flex flex-col items-center justify-center">
<AlertOctagon className="h-10 w-10 mx-auto mb-4 opacity-20 animate-pulse" />
Loading issues...
</div>
)
}
return (
<div className="space-y-4">
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-3 py-2 border-b border-white/5 mb-2 bg-slate-950/80 backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
<AlertOctagon className="h-3 w-3 text-rose-400" />
Deduplicated Issues
</span>
{/* Status filter chips */}
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
{statusFilters.map(({ value, label }) => (
<button
key={value}
onClick={() => setStatusFilter(value)}
className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
statusFilter === value
? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
{label}
<span className="ml-1 opacity-60">{statusCounts[value]}</span>
</button>
))}
</div>
<button
onClick={() => setExcludeSpamIPs((value) => !value)}
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
excludeSpamIPs
? "border-amber-400/40 bg-amber-400/15 text-amber-200"
: "border-white/10 text-slate-500 hover:border-white/20 hover:text-slate-300"
}`}
title="Hide HTTP errors whose recorded source IP is on the honeypot list"
>
<ShieldBan className="h-3 w-3" />
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
</button>
{/* Kind filter chips */}
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
{kindFilters.map(({ value, label, Icon }) => (
<button
key={value}
onClick={() => setKindFilter(value)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
kindFilter === value
? "bg-violet-500/20 text-violet-300 ring-1 ring-violet-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
<Icon className="h-3 w-3" />
{label}
</button>
))}
</div>
<span className="text-[10px] text-slate-500">
{filteredIssues.length} shown
</span>
</div>
<div className="flex items-center gap-3">
{error && <span className="text-[10px] text-rose-400">Failed to load: {error}</span>}
<button
onClick={() => {
const next = !showSpamIPs
setShowSpamIPs(next)
if (next && spamIPs.length === 0) void loadSpamIPs()
}}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<ShieldBan className="h-3 w-3" />
Spam IPs ({spamIPs.length})
</button>
<button
onClick={() => refresh()}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
{showSpamIPs && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-3 text-xs text-slate-300">
<div className="flex items-center justify-between gap-3 mb-2">
<div>
<div className="font-bold text-amber-200">Promoted honeypot IPs</div>
<div className="text-[10px] text-slate-500">Used by the ingress tarpit synchronizer.</div>
</div>
<button
onClick={() => setShowSpamIPs(false)}
className="rounded p-1 text-slate-500 hover:text-white"
aria-label="Close spam IP list"
>
<X className="h-4 w-4" />
</button>
</div>
{spamIPsLoading && <div className="text-slate-500">Loading…</div>}
{spamIPsError && <div className="text-rose-300">Failed to load: {spamIPsError}</div>}
{!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
<div className="text-slate-500">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-lg bg-black/20 px-2.5 py-1.5 font-mono">
<span className="text-slate-200">{entry.ip}</span>
<span className="text-[10px] text-amber-300">×{entry.count}</span>
</div>
))}
</div>
)}
</div>
)}
{filteredIssues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
<AlertOctagon className="h-6 w-6 text-emerald-400/60" />
</div>
<p className="text-sm font-medium text-slate-300">
{issues.length === 0 ? "No issues detected" : "No issues match your filters"}
</p>
<p className="text-xs mt-1 text-slate-600">
{issues.length === 0
? "Error-status spans and error/fatal-severity logs will show up here as they arrive."
: "Try adjusting your filters or search query."}
</p>
</div>
) : (
<div className="space-y-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
{filteredIssues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
onStatusChange={onStatusChange}
statusBusy={statusBusy}
/>
))}
</div>
)}
</div>
)
}
export default IssuesVisualizer