fixes
This commit is contained in:
1 parent
f30adf05af
commit
8824af8699
29 files changed
+1201
-115
No files matched your search
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user