fixes
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s

This commit is contained in:
mats committed 2026-09-20 09:52:49 +02:00
1 parent f30adf05af
commit 8824af8699
29 files changed
+1201 -115

No files matched your search

+70 -6
View File
@@ -34,9 +34,11 @@ import {
} from "../lib/issue-utils"
import { useSubRoute } from "../lib/routes"
import { CopyRef } from "./common/CopyRef"
import { IpButton } from "./common/IpButton"
import { IssueOccurrences } from "./IssueOccurrences"
import { NeedleTestModal } from "./NeedleTestModal"
import { FilterBar, type FilterChip } from "./FilterBar"
import { UA_CLASSES, uaClassLabel } from "../lib/ua-class"
import type { Issue, IssueSample } from "../lib/types"
export const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
@@ -46,6 +48,12 @@ export const kindMeta: Record<string, { label: string; Icon: typeof Activity }>
}
type StatusFilter = "all" | "open" | "resolved" | "ignored"
const without = (set: Set<string>, value: string) => {
const next = new Set(set)
next.delete(value)
return next
}
type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
interface HoneypotIP {
@@ -438,6 +446,9 @@ const IssueCard = ({
{(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)}
</span>
)}
{(contextSample?.sourceIp || latestSample?.sourceIp) && (
<IpButton ip={(contextSample?.sourceIp || latestSample?.sourceIp)!} />
)}
{(contextSample?.release || latestSample?.release) && (
<span>release: {contextSample?.release || latestSample?.release}</span>
)}
@@ -666,6 +677,11 @@ export const IssuesVisualizer = ({
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
const [detectorTestLoading, setDetectorTestLoading] = useState(false)
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
// Client-class filters: an issue matches "client" when any sample came from
// one of these classes, and is hidden by "not client" when every classified
// sample did (a mixed human + bot issue stays visible).
const [clientInclude, setClientInclude] = useState<Set<string>>(new Set())
const [clientExclude, setClientExclude] = useState<Set<string>>(new Set())
const [showSpamIPs, setShowSpamIPs] = useState(false)
const [spamIPs, setSpamIPs] = useState<HoneypotIP[]>([])
const [spamIPsLoading, setSpamIPsLoading] = useState(false)
@@ -710,6 +726,11 @@ export const IssuesVisualizer = ({
) {
return false
}
if (clientInclude.size > 0 || clientExclude.size > 0) {
const classes = issue.samples.map((s) => s.uaClass).filter((c): c is string => Boolean(c))
if (clientInclude.size > 0 && !classes.some((c) => clientInclude.has(c))) return false
if (clientExclude.size > 0 && classes.length > 0 && classes.every((c) => clientExclude.has(c))) return false
}
if (selectedServices.size > 0) {
const matches =
selectedServices.has(issue.service) ||
@@ -724,7 +745,7 @@ export const IssuesVisualizer = ({
}
return true
})
}, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet])
}, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet, clientInclude, clientExclude])
const { primaryIssues, secondaryIssues } = useMemo(() => {
const sorted = [...filteredIssues].sort((a, b) => b.count - a.count)
@@ -753,7 +774,12 @@ export const IssuesVisualizer = ({
}
const activeFilterCount =
selectedServices.size + (statusFilter !== "all" ? 1 : 0) + (excludeSpamIPs ? 1 : 0) + (searchQuery ? 1 : 0)
selectedServices.size +
clientInclude.size +
clientExclude.size +
(statusFilter !== "all" ? 1 : 0) +
(excludeSpamIPs ? 1 : 0) +
(searchQuery ? 1 : 0)
const chips: FilterChip[] = [
...Array.from(selectedServices).map((service) => ({
@@ -763,6 +789,20 @@ export const IssuesVisualizer = ({
tone: "neutral" as const,
onRemove: () => toggleService(service),
})),
...Array.from(clientInclude).map((c) => ({
id: `client-${c}`,
filterKey: "client",
value: uaClassLabel(c),
tone: "neutral" as const,
onRemove: () => setClientInclude((prev) => without(prev, c)),
})),
...Array.from(clientExclude).map((c) => ({
id: `not-client-${c}`,
filterKey: "not client",
value: uaClassLabel(c),
tone: "neutral" as const,
onRemove: () => setClientExclude((prev) => without(prev, c)),
})),
...(statusFilter !== "all"
? [
{
@@ -798,15 +838,39 @@ export const IssuesVisualizer = ({
: []),
]
const addFilterOptions = uniqueServices
.filter((s) => !selectedServices.has(s))
.map((s) => ({ value: s, label: s }))
const addFilterOptions = [
...uniqueServices.filter((s) => !selectedServices.has(s)).map((s) => ({ value: s, label: s })),
...UA_CLASSES.filter((c) => !clientInclude.has(c.value)).map((c) => ({
value: `client:${c.value}`,
label: `client: ${c.label}`,
})),
...UA_CLASSES.filter((c) => !clientExclude.has(c.value)).map((c) => ({
value: `not-client:${c.value}`,
label: `hide client: ${c.label}`,
})),
]
const addFilter = (value: string) => {
if (value.startsWith("client:")) {
const c = value.slice("client:".length)
setClientInclude((prev) => new Set(prev).add(c))
setClientExclude((prev) => without(prev, c))
} else if (value.startsWith("not-client:")) {
const c = value.slice("not-client:".length)
setClientExclude((prev) => new Set(prev).add(c))
setClientInclude((prev) => without(prev, c))
} else {
toggleService(value)
}
}
const clearAll = () => {
resetFilters()
setSearchQuery("")
setStatusFilter("all")
setExcludeSpamIPs(false)
setClientInclude(new Set())
setClientExclude(new Set())
}
if (loading && issues.length === 0) {
@@ -852,7 +916,7 @@ export const IssuesVisualizer = ({
}}
chips={chips}
addFilterOptions={addFilterOptions}
onAddFilter={(v) => toggleService(v)}
onAddFilter={addFilter}
matchText={`Matching ${filteredIssues.length} of ${issues.length} issues · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`}
onClearAll={activeFilterCount > 0 ? clearAll : undefined}
/>
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react"
import { Globe, X } from "lucide-react"
import { authFetch } from "../../lib/auth"
import { DetailField } from "./DetailField"
interface IpInfo {
ip: string
configured: boolean
scope?: string
country?: string
countryCode?: string
inEu?: boolean
region?: string
city?: string
postal?: string
timezone?: string
latitude?: number
longitude?: number
network?: string
asn?: number
asOrg?: string
}
type State = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; info: IpInfo }
const IpDialog = ({ ip, onClose }: { ip: string; onClose: () => void }) => {
const ref = useRef<HTMLDialogElement>(null)
const [state, setState] = useState<State>({ status: "loading" })
useEffect(() => {
const dialog = ref.current
if (dialog && !dialog.open) dialog.showModal()
}, [])
useEffect(() => {
let cancelled = false
authFetch(`/api/ip/${encodeURIComponent(ip)}`)
.then(async (resp) => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as IpInfo
})
.then((info) => !cancelled && setState({ status: "ok", info }))
.catch((e: unknown) => !cancelled && setState({ status: "error", message: e instanceof Error ? e.message : String(e) }))
return () => {
cancelled = true
}
}, [ip])
const info = state.status === "ok" ? state.info : null
const place = info ? [info.city, info.region, info.country].filter(Boolean).join(", ") : ""
const hasGeo = Boolean(place || info?.asn || info?.network)
return (
<dialog
ref={ref}
onClose={onClose}
onClick={(e) => e.target === ref.current && ref.current?.close()}
aria-labelledby="ip-dialog-title"
className="m-auto w-[min(92vw,460px)] rounded-[14px] border border-ds-line bg-ds-surface p-0 text-ds-text backdrop:bg-ds-bg/70"
>
<div className="flex items-center justify-between gap-3 border-b border-ds-line px-4 py-3">
<h2
id="ip-dialog-title"
className="break-all font-[family-name:var(--font-display)] text-[15px] font-semibold"
>
<span className="font-[family-name:var(--font-data)] text-[13px]">{ip}</span>
</h2>
<button
type="button"
onClick={() => ref.current?.close()}
aria-label="Close"
className="shrink-0 rounded-md p-1 text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="space-y-4 px-4 py-4 text-[12.5px] text-ds-text-2">
{state.status === "loading" && <p>Looking up…</p>}
{state.status === "error" && <p className="text-ds-critical">Lookup failed: {state.message}</p>}
{info && !info.configured && (
<p>
No MaxMind database is configured. Set <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_CITY_DB</code>{" "}
and/or <code className="font-[family-name:var(--font-data)]">OTEL_GEOIP_ASN_DB</code> to the paths of GeoLite2
<code className="font-[family-name:var(--font-data)]"> .mmdb</code> files.
</p>
)}
{info?.scope && <p>This is a {info.scope} address, so it has no public location.</p>}
{info?.configured && !info.scope && !hasGeo && <p>The database has no record for this address.</p>}
{info && hasGeo && (
<dl className="grid grid-cols-2 gap-3">
<DetailField label="Location" value={place} />
<DetailField
label="Country code"
value={info.countryCode ? `${info.countryCode}${info.inEu ? " (EU)" : ""}` : ""}
/>
<DetailField label="Postal" value={info.postal} />
<DetailField label="Timezone" value={info.timezone} />
<DetailField label="ASN" value={info.asn ? `AS${info.asn}` : ""} />
<DetailField label="Organisation" value={info.asOrg} />
<DetailField label="Network" value={info.network} />
<DetailField
label="Coordinates"
value={
info.latitude !== undefined && info.longitude !== undefined
? `${info.latitude.toFixed(2)}, ${info.longitude.toFixed(2)}`
: ""
}
/>
</dl>
)}
{hasGeo && (
<p className="text-[11px] text-ds-text-3">Approximate, from the local MaxMind database. Not precise enough to identify a person.</p>
)}
</div>
</dialog>
)
}
/** An IP address that opens a details dialog (geo + ASN from MaxMind) on click. */
export const IpButton = ({ ip, prefix }: { ip: string; prefix?: string }) => {
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`IP details for ${ip}`}
className="inline-flex items-center gap-1.5 rounded font-[family-name:var(--font-data)] text-ds-text-3 underline decoration-dotted underline-offset-2 hover:text-ds-accent"
>
<Globe className="h-3 w-3 shrink-0" aria-hidden="true" />
{prefix}
{ip}
</button>
{open && <IpDialog ip={ip} onClose={() => setOpen(false)} />}
</>
)
}
@@ -1,8 +1,10 @@
import { ExternalLink } from "lucide-react"
import { DetailField } from "../common/DetailField"
import { IpButton } from "../common/IpButton"
import { formatRelativeTime } from "../../lib/timeUtils"
import { parseBreadcrumbs } from "../../lib/issue-utils"
import type { IssueSample } from "../../lib/types"
import { uaClassLabel } from "../../lib/ua-class"
export const OccurrenceDetail = ({ sample }: { sample: IssueSample }) => {
const stack = sample.resolvedStacktrace || sample.stacktrace
@@ -21,8 +23,16 @@ export const OccurrenceDetail = ({ sample }: { sample: IssueSample }) => {
<DetailField label="Release" value={sample.release} />
<DetailField label="Environment" value={sample.environment} />
<DetailField label="Span" value={sample.spanId} />
<DetailField label="Source IP" value={sample.sourceIp} />
{sample.sourceIp && (
<div className="min-w-0">
<dt className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">Source IP</dt>
<dd className="text-[12px]">
<IpButton ip={sample.sourceIp} />
</dd>
</div>
)}
<DetailField label="User agent" value={sample.userAgent} />
<DetailField label="Client" value={uaClassLabel(sample.uaClass)} />
</dl>
{sample.url && (
<a href={sample.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 break-all text-[12px] text-ds-accent">