more testing
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s

This commit is contained in:
mats committed 2026-09-19 16:44:51 +02:00
1 parent 1aa264675e
commit 2da2649a2f
11 files changed
+293 -51

No files matched your search

+26 -5
View File
@@ -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={
<>
+20 -4
View File
@@ -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">
+41 -16
View File
@@ -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>
+22 -8
View File
@@ -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" />
+33
View File
@@ -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>
)
}
+2 -2
View File
@@ -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
+45 -3
View File
@@ -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)