more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user