more testing
This commit is contained in:
1 parent
1aa264675e
commit
2da2649a2f
11 files changed
+293
-51
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { CopyRef } from "./common/CopyRef"
|
||||
import { MasterDetail } from "./MasterDetail"
|
||||
import { BackButton } from "./common/BackButton"
|
||||
import { SectionLabel } from "./common/DetailField"
|
||||
@@ -26,15 +27,18 @@ interface Row {
|
||||
/**
|
||||
* Focused view of one issue: an overview of every trace it occurred in
|
||||
* (server-side summaries, paged), with the selected trace's waterfall and its
|
||||
* occurrences below. `initialTraceId` preselects the trace the user came from.
|
||||
* occurrences below. The selected trace comes from the URL (`traceId`).
|
||||
*/
|
||||
export const IssueOccurrences = ({
|
||||
issue,
|
||||
initialTraceId,
|
||||
traceId: routeTraceId,
|
||||
onTraceChange,
|
||||
onBack,
|
||||
}: {
|
||||
issue: Issue
|
||||
initialTraceId?: string
|
||||
/** Trace named by the URL; the URL is the source of truth for the selection. */
|
||||
traceId?: string
|
||||
onTraceChange?: (traceId: string | null) => void
|
||||
onBack: () => void
|
||||
}) => {
|
||||
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||
@@ -59,7 +63,12 @@ export const IssueOccurrences = ({
|
||||
)
|
||||
}, [traces, untraced, sort])
|
||||
|
||||
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
||||
const [pickedLocal, setPickedLocal] = useState<string | null>(null)
|
||||
const picked = routeTraceId ?? pickedLocal
|
||||
const setPicked = (key: string) => {
|
||||
setPickedLocal(key)
|
||||
onTraceChange?.(rows.find((r) => r.key === key)?.traceId ?? null)
|
||||
}
|
||||
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||
const selectedTraceId = selected?.traceId
|
||||
|
||||
@@ -81,7 +90,19 @@ export const IssueOccurrences = ({
|
||||
<div className="flex flex-col gap-5">
|
||||
<StatHeader
|
||||
back={<BackButton label="All issues" onClick={onBack} />}
|
||||
eyebrow={`service=${issue.service}`}
|
||||
eyebrow={
|
||||
<>
|
||||
<span>service={issue.service}</span>
|
||||
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} label="Copy issue link" />
|
||||
{selectedTraceId && (
|
||||
<CopyRef
|
||||
id={selectedTraceId}
|
||||
path={`/issues/${encodeURIComponent(issue.id)}/trace/${encodeURIComponent(selectedTraceId)}`}
|
||||
label="Copy trace link"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={issue.title}
|
||||
stats={
|
||||
<>
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
strongestCorrelate,
|
||||
type SeverityTone,
|
||||
} from "../lib/issue-utils"
|
||||
import { useSubRoute } from "../lib/routes"
|
||||
import { CopyRef } from "./common/CopyRef"
|
||||
import { IssueOccurrences } from "./IssueOccurrences"
|
||||
import { NeedleTestModal } from "./NeedleTestModal"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
@@ -343,6 +345,7 @@ const IssueCard = ({
|
||||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{meta.label}
|
||||
</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{" "}
|
||||
{formatRelativeTime(issue.lastSeen / 1e6)}
|
||||
@@ -653,8 +656,10 @@ export const IssuesVisualizer = ({
|
||||
}: IssuesVisualizerProps) => {
|
||||
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
||||
useObservabilityData()
|
||||
const [focus, setFocus] = useState<{ issueId: string; traceId?: string } | null>(null)
|
||||
const focusIssue = (issue: Issue, traceId?: string) => setFocus({ issueId: issue.id, traceId })
|
||||
// /issues/<issueId>[/trace/<traceId>] - the focused issue and trace live in the URL.
|
||||
const { segments, go } = useSubRoute()
|
||||
const focus = segments[0] ? { issueId: segments[0], traceId: segments[1] === "trace" ? segments[2] : undefined } : null
|
||||
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 [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||||
@@ -819,11 +824,22 @@ export const IssuesVisualizer = ({
|
||||
<IssueOccurrences
|
||||
key={focus.issueId}
|
||||
issue={focusedIssue}
|
||||
initialTraceId={focus.traceId}
|
||||
onBack={() => setFocus(null)}
|
||||
traceId={focus.traceId}
|
||||
onTraceChange={(traceId) => go(traceId ? [focus.issueId, "trace", traceId] : [focus.issueId], { replace: true })}
|
||||
onBack={() => go([])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (focus) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<p className="text-[14px] font-medium text-ds-text">Issue not found</p>
|
||||
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{focus.issueId}</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">It may have been evicted, or the link is from another environment.</p>
|
||||
<button type="button" onClick={() => go([])} className="inline-flex h-9 items-center rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:text-ds-text">All issues</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
|
||||
import { useSubRoute } from "../lib/routes"
|
||||
import { CopyRef } from "./common/CopyRef"
|
||||
import { usePatterns } from "../hooks/usePatterns"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
|
||||
@@ -15,14 +17,18 @@ const mergeIssues = (members: Issue[]): Issue => {
|
||||
return { ...members[0], trend }
|
||||
}
|
||||
|
||||
const PatternCard = ({ pattern, members, similar, review }: {
|
||||
const PatternCard = ({ pattern, members, similar, review, expanded, selected, onExpand, onSelect }: {
|
||||
pattern: IssuePattern
|
||||
members: Issue[]
|
||||
expanded: boolean
|
||||
/** Member issue whose similar-issue suggestions are open (from the URL). */
|
||||
selected: string | null
|
||||
onExpand: (open: boolean) => void
|
||||
onSelect: (issueId: string | null) => void
|
||||
similar: (id: string) => Promise<SimilarIssue[]>
|
||||
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const card = useRef<HTMLElement>(null)
|
||||
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
@@ -32,14 +38,20 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
const meta = kindMeta[pattern.kind] ?? { label: pattern.kind, Icon: Bug }
|
||||
const title = [...members].sort((a, b) => b.count - a.count)[0]?.title ?? pattern.signature.split("\u0000")[0]
|
||||
|
||||
const select = async (issue: Issue) => {
|
||||
if (selected === issue.id) { setSelected(null); return }
|
||||
setSelected(issue.id)
|
||||
if (!related[issue.id]) {
|
||||
const suggestions = await similar(issue.id)
|
||||
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
|
||||
}
|
||||
}
|
||||
// A deep link to /patterns/<id> scrolls its card into view once.
|
||||
useEffect(() => {
|
||||
if (expanded) card.current?.scrollIntoView({ block: "start" })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on first render of a linked card
|
||||
}, [])
|
||||
|
||||
// Suggestions load whenever the URL selects a member issue.
|
||||
useEffect(() => {
|
||||
if (!selected || related[selected]) return
|
||||
void similar(selected)
|
||||
.then((suggestions) => setRelated((prev) => ({ ...prev, [selected]: suggestions })))
|
||||
.catch(() => setRelated((prev) => ({ ...prev, [selected]: [] })))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected])
|
||||
|
||||
const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
|
||||
const key = `${issue.id}:${item.issue.id}`
|
||||
@@ -53,9 +65,9 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
const selectedIssue = members.find((m) => m.id === selected)
|
||||
|
||||
return (
|
||||
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
||||
<article ref={card} className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
||||
<div className="flex w-full flex-wrap items-start gap-4 px-5 pt-[18px] pb-1">
|
||||
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex min-w-0 flex-1 flex-col gap-1.5 text-left">
|
||||
<button type="button" onClick={() => onExpand(!expanded)} aria-expanded={expanded} className="flex min-w-0 flex-1 flex-col gap-1.5 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className={`inline-flex h-[23px] items-center rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||||
{openCount > 0 ? `${openCount} open` : "All closed"}
|
||||
@@ -63,6 +75,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
<span className="inline-flex h-[23px] items-center gap-1.5 rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||||
<meta.Icon className="h-3 w-3" aria-hidden="true" />{meta.label}
|
||||
</span>
|
||||
<CopyRef id={pattern.id} path={`/patterns/${encodeURIComponent(pattern.id)}`} label="Copy pattern link" />
|
||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
|
||||
</div>
|
||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
|
||||
@@ -70,7 +83,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
onClick={() => onExpand(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} pattern ${title}`}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
@@ -96,7 +109,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => void select(issue)}
|
||||
onClick={() => onSelect(selected === issue.id ? null : issue.id)}
|
||||
aria-pressed={selected === issue.id}
|
||||
className={`flex items-center gap-3 rounded-[9px] border bg-ds-surface px-2.5 py-1.5 text-left hover:border-ds-accent/40 ${selected === issue.id ? "border-ds-accent/50" : "border-ds-line"}`}
|
||||
>
|
||||
@@ -135,6 +148,8 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
||||
|
||||
export function PatternsVisualizer() {
|
||||
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
|
||||
// /patterns/<patternId>[/<issueId>]: which card is open and which member is reviewed.
|
||||
const { segments, go } = useSubRoute()
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
const visible = useMemo(() => {
|
||||
@@ -170,6 +185,12 @@ export function PatternsVisualizer() {
|
||||
|
||||
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
|
||||
|
||||
{segments[0] && !patterns.some((p) => p.id === segments[0]) && patterns.length > 0 && (
|
||||
<p className="text-[12px] text-ds-warn">
|
||||
Pattern <span className="font-[family-name:var(--font-data)]">{segments[0]}</span> not found (it may have been merged or evicted).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||
<GitBranch className="h-8 w-8 text-ds-text-3/60" aria-hidden="true" />
|
||||
@@ -184,6 +205,10 @@ export function PatternsVisualizer() {
|
||||
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
|
||||
similar={similar}
|
||||
review={review}
|
||||
expanded={segments[0] === pattern.id}
|
||||
selected={segments[0] === pattern.id ? (segments[1] ?? null) : null}
|
||||
onExpand={(open) => go(open ? [pattern.id] : [])}
|
||||
onSelect={(issueId) => go(issueId ? [pattern.id, issueId] : [pattern.id])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { CopyRef } from "./common/CopyRef"
|
||||
import { triggerTestError } from "../lib/test-error"
|
||||
import { authFetch } from "../lib/auth"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
@@ -8,6 +9,8 @@ import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
|
||||
|
||||
interface JsErrorGroup {
|
||||
key: string
|
||||
/** Stable short id derived from the error's type and message, used in URLs. */
|
||||
id: string
|
||||
message: string
|
||||
exceptionType?: string
|
||||
count: number
|
||||
@@ -17,6 +20,12 @@ interface JsErrorGroup {
|
||||
sample?: RumErrorEvent
|
||||
}
|
||||
|
||||
const hashId = (text: string): string => {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
|
||||
return (h >>> 0).toString(16).padStart(8, "0")
|
||||
}
|
||||
|
||||
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
||||
const groups = new Map<string, JsErrorGroup>()
|
||||
for (const session of sessions) {
|
||||
@@ -25,7 +34,7 @@ const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
||||
const key = `${err.exceptionType ?? ""}|${err.message}`
|
||||
let g = groups.get(key)
|
||||
if (!g) {
|
||||
g = { key, message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
|
||||
g = { key, id: hashId(key), message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
|
||||
groups.set(key, g)
|
||||
}
|
||||
g.count += 1
|
||||
@@ -73,8 +82,12 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
||||
return { resolved, frames }
|
||||
}
|
||||
|
||||
const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[] }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const JsErrorCard = ({ group, resolvedStack, frames, expanded, onToggle }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[]; expanded: boolean; onToggle: () => void }) => {
|
||||
const card = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
if (expanded) card.current?.scrollIntoView({ block: "start" })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only when a link opens this card on first render
|
||||
}, [])
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const raw = group.sample?.stacktrace
|
||||
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
||||
@@ -82,8 +95,8 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
|
||||
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
||||
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex items-start gap-4 px-5 pt-4 text-left">
|
||||
<article ref={card} className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
||||
<button type="button" onClick={onToggle} aria-expanded={expanded} className="flex items-start gap-4 px-5 pt-4 text-left">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{group.exceptionType && (
|
||||
@@ -92,6 +105,7 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
|
||||
{mapped && (
|
||||
<span className="inline-flex h-[23px] items-center rounded-[6px] border border-ds-ok/30 px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-ok">Source-mapped</span>
|
||||
)}
|
||||
<CopyRef id={group.id} path={`/rum/errors/${group.id}`} label="Copy error link" />
|
||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
|
||||
</div>
|
||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
|
||||
@@ -142,7 +156,7 @@ const TestErrorButton = () => (
|
||||
</button>
|
||||
)
|
||||
|
||||
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
||||
export const RumJsErrors = ({ sessions, openId, onOpen }: { sessions: RumSession[]; openId: string | null; onOpen: (id: string | null) => void }) => {
|
||||
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
||||
const { resolved, frames } = useResolvedStacks(groups)
|
||||
|
||||
@@ -155,5 +169,5 @@ export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} />)}</div>
|
||||
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} expanded={openId === g.id} onToggle={() => onOpen(openId === g.id ? null : g.id)} />)}</div>
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type RumTimelineEntry,
|
||||
type VitalRating,
|
||||
} from "../lib/rum-utils"
|
||||
import { useSubRoute } from "../lib/routes"
|
||||
import { CopyRef } from "./common/CopyRef"
|
||||
import { RumJsErrors } from "./RumJsErrors"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
|
||||
@@ -266,6 +268,7 @@ const SessionDetail = ({
|
||||
{session.environment ? ` · ${session.environment}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<CopyRef id={session.id} path={`/rum/session/${encodeURIComponent(session.id)}`} label="Copy session link" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySessionId}
|
||||
@@ -395,8 +398,12 @@ export const RumSessionsVisualizer = () => {
|
||||
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
|
||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||
const [sortMode, setSortMode] = useState<SortMode>("worst")
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [jsFocus, setJsFocus] = useState(false)
|
||||
// /rum, /rum/session/<sessionId>, /rum/errors[/<errorId>]
|
||||
const { segments, go } = useSubRoute()
|
||||
const jsFocus = segments[0] === "errors"
|
||||
const selectedSessionId = segments[0] === "session" ? (segments[1] ?? null) : null
|
||||
const setJsFocus = (on: boolean) => go(on ? ["errors"] : [])
|
||||
const setSelectedSessionId = (id: string) => go(["session", id])
|
||||
|
||||
const sessions = useMemo(() => buildRumSessions(logs), [logs])
|
||||
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
||||
@@ -532,7 +539,7 @@ export const RumSessionsVisualizer = () => {
|
||||
value={String(aggregates.totalJsErrors)}
|
||||
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
|
||||
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
|
||||
onClick={() => setJsFocus((v) => !v)}
|
||||
onClick={() => setJsFocus(!jsFocus)}
|
||||
active={jsFocus}
|
||||
/>
|
||||
<StatTile
|
||||
@@ -550,7 +557,7 @@ export const RumSessionsVisualizer = () => {
|
||||
</div>
|
||||
|
||||
{jsFocus ? (
|
||||
<RumJsErrors sessions={filtered} />
|
||||
<RumJsErrors sessions={filtered} openId={segments[1] ?? null} onOpen={(id) => go(id ? ["errors", id] : ["errors"])} />
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
|
||||
<AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react"
|
||||
import { Check, Link2 } from "lucide-react"
|
||||
import { shortId } from "../../lib/routes"
|
||||
|
||||
/**
|
||||
* Shows a short id and copies the full permalink (origin + `path`) on click,
|
||||
* so any issue, pattern, session or error can be referenced in a message.
|
||||
* The full id is in the tooltip and in the copied URL.
|
||||
*/
|
||||
export const CopyRef = ({ id, path, label = "Copy link" }: { id: string; path: string; label?: string }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void navigator.clipboard
|
||||
.writeText(window.location.origin + path)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
title={`${label}: ${id}`}
|
||||
aria-label={`${label} for ${id}`}
|
||||
className="inline-flex h-[23px] shrink-0 items-center gap-1 rounded-[6px] border border-ds-line px-1.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3 hover:border-ds-line-strong hover:text-ds-text-2"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-ds-ok" aria-hidden="true" /> : <Link2 className="h-3 w-3" aria-hidden="true" />}
|
||||
{copied ? "copied" : shortId(id)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const StatHeader = ({
|
||||
back,
|
||||
stats,
|
||||
}: {
|
||||
eyebrow?: string
|
||||
eyebrow?: React.ReactNode
|
||||
title: string
|
||||
back?: React.ReactNode
|
||||
stats: React.ReactNode
|
||||
@@ -24,7 +24,7 @@ export const StatHeader = ({
|
||||
<div className="flex flex-wrap items-end gap-x-7 gap-y-3">
|
||||
{back}
|
||||
<div className="min-w-0 flex-1 basis-[280px]">
|
||||
{eyebrow && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</p>}
|
||||
{eyebrow && <div className="flex flex-wrap items-center gap-2 font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</div>}
|
||||
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
|
||||
import { useLocation } from "wouter"
|
||||
import { tabForPath, pathForTab } from "../lib/routes"
|
||||
import { tabForPath, pathForTab, splitPath, joinPath } from "../lib/routes"
|
||||
import type {
|
||||
MetricData,
|
||||
OtelHistoryMessage,
|
||||
@@ -150,11 +150,31 @@ export function ObservabilityProvider({
|
||||
const setActiveTabWithRoute = useCallback(
|
||||
(tab: string) => {
|
||||
setActiveTab(tab)
|
||||
// Already inside this tab (possibly on a deep link): keep the sub-route.
|
||||
if (tabForPath(window.location.pathname) === tab) return
|
||||
const path = pathForTab(tab)
|
||||
if (path && path !== window.location.pathname) navigate(path)
|
||||
if (path) navigate(path)
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
// Trace selection <-> /traces/<traceId>[/<spanId>]. The URL is adopted when
|
||||
// it names a trace; a bare /traces never clears the selection, because other
|
||||
// views set the trace first and switch tabs second.
|
||||
useEffect(() => {
|
||||
const { base, rest } = splitPath(location)
|
||||
if (base !== "/traces" || !rest[0]) return
|
||||
setActiveTraceId((prev) => (prev === rest[0] ? prev : rest[0]))
|
||||
setActiveSpanId((prev) => (prev === (rest[1] ?? null) ? prev : (rest[1] ?? null)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only follow URL changes
|
||||
}, [location])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "tab1" || !activeTraceId) return
|
||||
const target = joinPath("/traces", activeSpanId ? [activeTraceId, activeSpanId] : [activeTraceId])
|
||||
if (target !== window.location.pathname) navigate(target)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- location is read imperatively
|
||||
}, [activeTab, activeTraceId, activeSpanId, navigate])
|
||||
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false)
|
||||
// Trace ids we already have spans for, so selecting a trace that fell
|
||||
// outside the initial history window (or arrived before this session
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
|
||||
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
|
||||
// exactly the view you requested, and URLs are shareable/bookmarkable.
|
||||
@@ -12,14 +15,28 @@ export const TAB_PATHS: Record<string, string> = {
|
||||
"/setup": "tab4",
|
||||
"/trends": "tab5",
|
||||
"/search": "tab6",
|
||||
"/frontend": "tab7",
|
||||
"/rum": "tab7",
|
||||
"/frontend": "tab7",
|
||||
}
|
||||
|
||||
|
||||
/** Tab value for a location pathname, or null when the path isn't a tab. */
|
||||
/** Splits "/issues/abc/trace/def" into base "/issues" and rest ["abc","trace","def"]. */
|
||||
export function splitPath(path: string): { base: string; rest: string[] } {
|
||||
const parts = path.split("/").filter(Boolean)
|
||||
return { base: "/" + (parts[0] ?? ""), rest: parts.slice(1).map((p) => decodeURIComponent(p)) }
|
||||
}
|
||||
|
||||
/** Builds a path below a tab base, e.g. joinPath("/issues", ["abc", "trace", "def"]). */
|
||||
export function joinPath(base: string, rest: string[]): string {
|
||||
return [base, ...rest.map((p) => encodeURIComponent(p))].join("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab value for a location pathname, or null when the path isn't a tab.
|
||||
* Deeper paths ("/issues/<id>/trace/<id>") belong to their first segment's tab.
|
||||
*/
|
||||
export function tabForPath(path: string): string | null {
|
||||
return TAB_PATHS[path] ?? null
|
||||
return TAB_PATHS[splitPath(path).base] ?? null
|
||||
}
|
||||
|
||||
/** Canonical path for a tab value, or null when the tab has no route. */
|
||||
@@ -29,3 +46,28 @@ export function pathForTab(tab: string): string | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-route below the current tab: `segments` are the path parts after the
|
||||
* tab base and `go` rewrites them, so selection state (which issue, which
|
||||
* trace, ...) lives in the URL and survives reloads, back/forward and sharing.
|
||||
*/
|
||||
export function useSubRoute(): {
|
||||
segments: string[]
|
||||
go: (segments: string[], options?: { replace?: boolean }) => void
|
||||
} {
|
||||
const [location, navigate] = useLocation()
|
||||
const { base, rest } = splitPath(location)
|
||||
const key = rest.join("/")
|
||||
// Stable identity per URL so effects can depend on `segments` directly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const segments = useMemo(() => rest, [key])
|
||||
const go = useCallback(
|
||||
(next: string[], options?: { replace?: boolean }) => navigate(joinPath(base, next), options),
|
||||
[base, navigate],
|
||||
)
|
||||
return { segments, go }
|
||||
}
|
||||
|
||||
/** Short reference for showing an id to humans (full id stays in the URL). */
|
||||
export const shortId = (id: string) => (id.length > 10 ? id.slice(0, 8) : id)
|
||||
Reference in new issue
Block a user