fix
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m15s

This commit is contained in:
mats committed 2026-09-20 11:39:20 +02:00
1 parent 645f923175
commit 2cf2cebf5c
20 files changed
+481 -51

No files matched your search

+73 -5
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from "react"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import {
Activity,
AlertOctagon,
@@ -56,6 +56,7 @@ const without = (set: Set<string>, value: string) => {
}
type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
type GroupBy = "none" | "service"
interface HoneypotIP {
ip: string
@@ -304,11 +305,13 @@ const IssueCard = ({
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)
@@ -337,6 +340,10 @@ const IssueCard = ({
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)
@@ -368,6 +375,18 @@ const IssueCard = ({
<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{" "}
@@ -464,6 +483,9 @@ const IssueCard = ({
{(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>
)}
@@ -688,6 +710,7 @@ export const IssuesVisualizer = ({
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)
@@ -720,6 +743,12 @@ export const IssuesVisualizer = ({
}
}
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 })
@@ -731,6 +760,7 @@ export const IssuesVisualizer = ({
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 = () => {
@@ -800,6 +830,18 @@ export const IssuesVisualizer = ({
}
}, [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 () => {
@@ -912,6 +954,7 @@ export const IssuesVisualizer = ({
resetFilters()
setSearchQuery("")
setStatusFilter("all")
setGroupBy("none")
setExcludeSpamIPs(false)
setClientInclude(new Set())
setClientExclude(new Set())
@@ -991,6 +1034,21 @@ export const IssuesVisualizer = ({
</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={() => {
@@ -1150,9 +1208,19 @@ export const IssuesVisualizer = ({
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{primaryIssues.map((issue) => (
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
<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>
)}
@@ -1174,7 +1242,7 @@ export const IssuesVisualizer = ({
{showSecondary && (
<div className="flex flex-col gap-3">
{secondaryIssues.map((issue) => (
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} detectedIPByAddress={detectedIPByAddress} />
))}
</div>
)}
+17 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import React, { useState, useRef, useEffect } from "react";
import {
Search,
Radar,
@@ -49,14 +49,13 @@ export const SearchAndWatcher: React.FC = () => {
setTimeout(() => setCopiedId(null), 1500);
};
const handleRunSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
const runSearch = async (query = searchQuery) => {
setSearchLoading(true);
setSearchError(null);
try {
const params = new URLSearchParams();
if (searchQuery.trim()) params.set("q", searchQuery.trim());
if (query.trim()) params.set("q", query.trim());
if (searchType) params.set("type", searchType);
params.set("limit", String(searchLimit));
@@ -83,6 +82,20 @@ export const SearchAndWatcher: React.FC = () => {
}
};
const handleRunSearch = async (e?: React.FormEvent) => {
e?.preventDefault();
await runSearch();
};
useEffect(() => {
const query = new URLSearchParams(window.location.search).get("q");
if (!query) return;
setSearchQuery(query);
void runSearch(query);
// The query is intentionally read once when arriving from a release link.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startWatcher = async () => {
if (isWatching) return;
setIsWatching(true);
+86 -24
View File
@@ -11,7 +11,7 @@ import {
XAxis,
YAxis,
} from "recharts"
import { AlertOctagon, GitCompareArrows, RefreshCw, Server } from "lucide-react"
import { AlertOctagon, ExternalLink, GitCompareArrows, Info, RefreshCw, Search, Server } from "lucide-react"
import { useStats } from "../hooks/useStats"
import type { StatsPoint } from "../lib/types"
@@ -20,9 +20,6 @@ const compactFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
})
// Stable palette for release lines; cycles when there are many releases.
const RELEASE_COLORS = ["var(--ds-accent)", "var(--ds-accent)", "var(--ds-ok)", "var(--ds-warn)", "var(--ds-accent)", "var(--ds-critical)"]
const label = (bucket: string, granularity: "hour" | "day") => {
const d = new Date(bucket.replace(" ", "T") + "Z")
if (Number.isNaN(d.getTime())) return bucket
@@ -59,6 +56,27 @@ const ChartTooltip = ({ active, payload, granularity }: any) => {
)
}
const ReleaseTooltip = ({ active, payload, label: bucket }: any) => {
if (!active || !payload?.length) return null
const d = new Date(String(bucket).replace(" ", "T") + "Z")
return (
<div className="rounded-xl border border-ds-line bg-ds-bg/95 p-3 text-xs">
<div className="mb-1 font-mono text-[10px] text-ds-text-2">
{d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })}
</div>
{payload.map((entry: any) => (
<div key={entry.name} className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5 text-ds-text-2">
<span className="h-1.5 w-1.5 rounded-full bg-ds-accent" />
{entry.name}
</span>
<span className="font-mono font-bold text-ds-text">{Number(entry.value).toFixed(2)}%</span>
</div>
))}
</div>
)
}
const rangeOptions = [24, 72, 168, 720] // 1d, 3d, 7d, 30d
export const TrendsVisualizer = () => {
@@ -81,12 +99,12 @@ export const TrendsVisualizer = () => {
const releases = useMemo(() => Object.keys(releaseSeries).sort(), [releaseSeries])
const releaseData = useMemo(() => {
// Recharts wants rows, not per-series arrays: bucket -> {release: errors}.
// Recharts wants rows, not per-series arrays: bucket -> {release: rate}.
const byBucket: Record<string, Record<string, number | string>> = {}
releases.forEach((rel) => {
releaseSeries[rel].forEach((p) => {
byBucket[p.bucket] = byBucket[p.bucket] || { bucket: p.bucket }
byBucket[p.bucket][rel] = p.errors
byBucket[p.bucket][rel] = p.total > 0 ? (p.errors / p.total) * 100 : 0
})
})
return Object.values(byBucket).sort((a, b) =>
@@ -94,6 +112,13 @@ export const TrendsVisualizer = () => {
)
}, [releaseSeries, releases])
const releaseSummary = useMemo(() => releases.map((release) => {
const points = releaseSeries[release]
const total = points.reduce((sum, point) => sum + point.total, 0)
const errors = points.reduce((sum, point) => sum + point.errors, 0)
return { release, total, errors, rate: total > 0 ? (errors / total) * 100 : 0 }
}), [releaseSeries, releases])
const totals = useMemo(() => {
let total = 0
let errors = 0
@@ -196,16 +221,6 @@ export const TrendsVisualizer = () => {
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={errorData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="100%">
<stop offset="5%" stopColor="var(--ds-accent)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--ds-accent)" stopOpacity={0} />
</linearGradient>
<linearGradient id="trendErrFill" x1="0" y1="0" x2="0" y2="100%">
<stop offset="5%" stopColor="var(--ds-critical)" stopOpacity={0.45} />
<stop offset="95%" stopColor="var(--ds-critical)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--ds-line-strong)" />
<XAxis
dataKey="bucket"
@@ -234,7 +249,7 @@ export const TrendsVisualizer = () => {
name="Total"
stroke="var(--ds-accent)"
strokeWidth={2}
fill="url(#trendFill)"
fill="var(--ds-accent-bg)"
isAnimationActive={false}
/>
<Area
@@ -243,7 +258,7 @@ export const TrendsVisualizer = () => {
name="Errors"
stroke="var(--ds-critical)"
strokeWidth={2}
fill="url(#trendErrFill)"
fill="var(--ds-critical-bg)"
isAnimationActive={false}
/>
</AreaChart>
@@ -254,15 +269,21 @@ export const TrendsVisualizer = () => {
{/* Release comparison */}
<section className="rounded-[14px] border border-ds-line bg-ds-surface-2 p-4">
<header className="mb-3 flex items-center justify-between">
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-ds-text-2">
<GitCompareArrows className="h-3.5 w-3.5 text-ds-accent" />
Errors by release
Release error rate
</h3>
<span className="text-[10px] uppercase tracking-wider text-ds-text-3">
{releases.length} release{releases.length === 1 ? "" : "s"} · overlay
{releases.length} release{releases.length === 1 ? "" : "s"} · percent of entries
</span>
</header>
<div className="mb-4 flex items-start gap-2 border border-ds-line bg-ds-fill p-3 text-[12px] leading-relaxed text-ds-text-2">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ds-accent" aria-hidden="true" />
<span>
Each point is <span className="font-[family-name:var(--font-data)] text-ds-text">errors ÷ all entries</span> carrying that release in the bucket. This is a rate, so releases with different traffic volumes can be compared. The table below keeps the underlying counts visible.
</span>
</div>
{releaseData.length === 0 || releases.length === 0 ? (
<p className="py-12 text-center text-sm text-ds-text-3">
No release-tagged telemetry in this range. Have services send{" "}
@@ -289,9 +310,10 @@ export const TrendsVisualizer = () => {
tickLine={false}
axisLine={false}
width={36}
tickFormatter={(v) => compactFormatter.format(v)}
tickFormatter={(v) => `${v}%`}
domain={[0, "auto"]}
/>
<Tooltip content={<ChartTooltip granularity="hour" />} />
<Tooltip content={<ReleaseTooltip />} />
<Legend
wrapperStyle={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.1em" }}
/>
@@ -300,7 +322,8 @@ export const TrendsVisualizer = () => {
key={rel}
type="monotone"
dataKey={rel}
stroke={RELEASE_COLORS[i % RELEASE_COLORS.length]}
stroke="var(--ds-accent)"
strokeDasharray={i % 3 === 1 ? "6 3" : i % 3 === 2 ? "2 2" : undefined}
strokeWidth={2}
dot={false}
isAnimationActive={false}
@@ -310,6 +333,45 @@ export const TrendsVisualizer = () => {
</ResponsiveContainer>
</div>
)}
{releaseSummary.length > 0 && (
<div className="mt-4 overflow-x-auto border-t border-ds-line pt-3">
<table className="w-full min-w-[560px] text-left text-[12px]">
<caption className="mb-2 text-left text-[10px] uppercase tracking-[0.15em] text-ds-text-3">
Range totals · {service || "all services"}
</caption>
<thead className="text-[10px] uppercase tracking-wider text-ds-text-3">
<tr>
<th className="pb-2 font-medium">Release</th>
<th className="pb-2 text-right font-medium">Entries</th>
<th className="pb-2 text-right font-medium">Errors</th>
<th className="pb-2 text-right font-medium">Error rate</th>
<th className="pb-2 text-right font-medium">Inspect</th>
</tr>
</thead>
<tbody>
{releaseSummary.map(({ release, total, errors, rate }) => (
<tr key={release} className="border-t border-ds-line text-ds-text-2">
<td className="py-2 font-[family-name:var(--font-data)] text-ds-text">{release}</td>
<td className="py-2 text-right font-[family-name:var(--font-data)]">{compactFormatter.format(total)}</td>
<td className="py-2 text-right font-[family-name:var(--font-data)]">{compactFormatter.format(errors)}</td>
<td className="py-2 text-right font-[family-name:var(--font-data)] text-ds-critical">{rate.toFixed(2)}%</td>
<td className="py-2 text-right">
<a
href={`/search?q=${encodeURIComponent(release)}`}
className="inline-flex items-center gap-1 text-ds-accent hover:underline"
title={`Search telemetry containing ${release}`}
>
<Search className="h-3.5 w-3.5" aria-hidden="true" />
Search
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="flex items-center justify-center gap-1.5 pb-2 text-center text-[10px] text-ds-text-3">