mreo ui
This commit is contained in:
1 parent
49f9bb3458
commit
7436966689
22 files changed
+989
-125
No files matched your search
@@ -4,7 +4,9 @@ import { BackButton } from "./common/BackButton"
|
||||
import { SectionLabel } from "./common/DetailField"
|
||||
import { Stat, StatHeader } from "./common/Stat"
|
||||
import { TraceDetail } from "./trace/TraceDetail"
|
||||
import { TraceOverviewHeader, TraceOverviewRow } from "./trace/TraceOverviewRow"
|
||||
import { SpanInspector } from "./TraceVisualization"
|
||||
import { Segmented } from "./common/Segmented"
|
||||
import { TraceListItem } from "./trace/TraceListItem"
|
||||
import { OccurrenceDetail } from "./issues/OccurrenceDetail"
|
||||
import { useIssueTraces } from "../hooks/useIssueTraces"
|
||||
import { useProcessedTraces } from "../hooks/useProcessedTraces"
|
||||
@@ -37,6 +39,8 @@ export const IssueOccurrences = ({
|
||||
}) => {
|
||||
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||
|
||||
const [sort, setSort] = useState<"recent" | "slowest" | "errors">("recent")
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const traced = traces.map((t) => ({
|
||||
key: t.traceId,
|
||||
@@ -47,8 +51,13 @@ export const IssueOccurrences = ({
|
||||
summary: t.summary,
|
||||
}))
|
||||
const loose = untraced.map((s, i) => ({ key: `s-${s.timestamp}-${i}`, samples: [s], ts: s.timestamp, hits: 1 }))
|
||||
return [...traced, ...loose].sort((a, b) => b.ts - a.ts)
|
||||
}, [traces, untraced])
|
||||
const all = [...traced, ...loose]
|
||||
const dur = (r: Row) => r.summary?.duration ?? 0
|
||||
const errs = (r: Row) => r.summary?.errorCount ?? 0
|
||||
return all.sort((a, b) =>
|
||||
sort === "slowest" ? dur(b) - dur(a) : sort === "errors" ? errs(b) - errs(a) || b.ts - a.ts : b.ts - a.ts,
|
||||
)
|
||||
}, [traces, untraced, sort])
|
||||
|
||||
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
||||
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||
@@ -94,15 +103,28 @@ export const IssueOccurrences = ({
|
||||
{loading && rows.length === 0 && <p className="text-[13px] text-ds-text-3">Loading traces…</p>}
|
||||
|
||||
<MasterDetail
|
||||
layout="stacked"
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
selectedKey={selected?.key ?? null}
|
||||
onSelect={setPicked}
|
||||
label="Traces containing this issue"
|
||||
listHeader={<TraceOverviewHeader />}
|
||||
listHeader={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SectionLabel>Traces ({rows.length}{hasMore ? "+" : ""})</SectionLabel>
|
||||
<Segmented
|
||||
label="Sort traces"
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: "recent", label: "Newest" },
|
||||
{ value: "slowest", label: "Slowest" },
|
||||
{ value: "errors", label: "Errors" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
renderItem={(r) => (
|
||||
<TraceOverviewRow
|
||||
<TraceListItem
|
||||
summary={r.summary}
|
||||
traceId={r.traceId}
|
||||
timestampNs={r.ts}
|
||||
@@ -111,7 +133,16 @@ export const IssueOccurrences = ({
|
||||
hits={r.hits}
|
||||
/>
|
||||
)}
|
||||
renderDetail={(r) => (
|
||||
storageKey="issue-traces"
|
||||
extendedTitle="Span"
|
||||
renderExtended={(r, spanId) => {
|
||||
const trace = r.traceId ? byId.get(r.traceId) : undefined
|
||||
const span = trace?.spans.find((sp) => sp.spanId === spanId)
|
||||
return span && trace ? (
|
||||
<SpanInspector span={span} traceTotalRange={trace.duration} />
|
||||
) : null
|
||||
}}
|
||||
renderDetail={(r, drill) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{r.traceId && (
|
||||
<TraceDetail
|
||||
@@ -119,6 +150,8 @@ export const IssueOccurrences = ({
|
||||
trace={byId.get(r.traceId)}
|
||||
loading={spansLoading && !failed.has(r.traceId)}
|
||||
spanId={r.samples[0]?.spanId}
|
||||
selectedSpanId={drill.extendedKey}
|
||||
onSelectSpan={(id) => (id ? drill.openExtended(id) : drill.closeExtended())}
|
||||
/>
|
||||
)}
|
||||
<SectionLabel>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { ResizablePanes, type PaneSize } from "./panes/ResizablePanes"
|
||||
|
||||
/** Handed to renderDetail so the detail can open (and close) the extended pane. */
|
||||
export interface DrillApi {
|
||||
/** Key of the currently open extended item, or null. */
|
||||
extendedKey: string | null
|
||||
openExtended: (key: string) => void
|
||||
closeExtended: () => void
|
||||
}
|
||||
|
||||
interface MasterDetailProps<T> {
|
||||
items: T[]
|
||||
@@ -6,28 +15,33 @@ interface MasterDetailProps<T> {
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
renderItem: (item: T, selected: boolean) => ReactNode
|
||||
renderDetail: (item: T) => ReactNode
|
||||
/** Accessible name for the list, e.g. "Occurrences". */
|
||||
renderDetail: (item: T, drill: DrillApi) => ReactNode
|
||||
/**
|
||||
* Third pane, opened from inside the detail via drill.openExtended(key).
|
||||
* Omit for a plain two-pane list/detail.
|
||||
*/
|
||||
renderExtended?: (item: T, key: string) => ReactNode
|
||||
extendedTitle?: ReactNode
|
||||
/** Accessible name for the list, e.g. "Traces". */
|
||||
label: string
|
||||
/** Optional heading rendered above the list. */
|
||||
/** Optional heading/controls rendered above the list. */
|
||||
listHeader?: ReactNode
|
||||
emptyDetail?: ReactNode
|
||||
/**
|
||||
* "split": list left, detail right (lists of cards). "stacked": full-width
|
||||
* list on top (tables/overviews), detail below.
|
||||
* "split": resizable list | detail [| extended] panes (default).
|
||||
* "stacked": full-width list on top (tables), detail below.
|
||||
*/
|
||||
layout?: "split" | "stacked"
|
||||
/** Split layout: Tailwind width class for the master column. */
|
||||
listWidth?: string
|
||||
/** Tailwind max-height class for the list (and, in split, the detail). */
|
||||
maxHeight?: string
|
||||
listSize?: PaneSize
|
||||
extendedSize?: PaneSize
|
||||
/** Persists pane widths per screen. */
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable master-detail layout: a selectable list on the left, the selected
|
||||
* item's detail on the right (stacked on narrow screens). Arrow keys move the
|
||||
* selection. Callers own selection state so it can be driven from elsewhere
|
||||
* (e.g. a deep link or "focus this trace").
|
||||
* Reusable drill-down layout. Selection is owned by the caller so it can be
|
||||
* driven from elsewhere (deep links, "focus this trace"); the extended pane's
|
||||
* state lives here and resets whenever the selection changes.
|
||||
*/
|
||||
export function MasterDetail<T>({
|
||||
items,
|
||||
@@ -36,78 +50,112 @@ export function MasterDetail<T>({
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderDetail,
|
||||
renderExtended,
|
||||
extendedTitle = "Details",
|
||||
label,
|
||||
listHeader,
|
||||
emptyDetail,
|
||||
layout = "split",
|
||||
listWidth = "lg:w-[380px]",
|
||||
maxHeight,
|
||||
listSize = { default: 320, min: 240, max: 560 },
|
||||
extendedSize = { default: 380, min: 280, max: 720 },
|
||||
storageKey,
|
||||
}: MasterDetailProps<T>) {
|
||||
const stacked = layout === "stacked"
|
||||
const maxH = maxHeight ?? (stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-260px)]")
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [extendedKey, setExtendedKey] = useState<string | null>(null)
|
||||
const selected = items.find((item) => getKey(item) === selectedKey) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedKey])
|
||||
|
||||
// A different selection invalidates whatever was open in the extended pane.
|
||||
useEffect(() => setExtendedKey(null), [selectedKey])
|
||||
|
||||
const select = (key: string) => {
|
||||
setExtendedKey(null)
|
||||
onSelect(key)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||
e.preventDefault()
|
||||
const idx = items.findIndex((item) => getKey(item) === selectedKey)
|
||||
const next = items[Math.min(items.length - 1, Math.max(0, idx + (e.key === "ArrowDown" ? 1 : -1)))]
|
||||
if (next) onSelect(getKey(next))
|
||||
if (next) select(getKey(next))
|
||||
}
|
||||
|
||||
const list = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{listHeader}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${
|
||||
stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-240px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const key = getKey(item)
|
||||
const isSelected = key === selectedKey
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => select(key)}
|
||||
className={`cursor-pointer rounded-[9px] border px-3 py-2 ${
|
||||
isSelected ? "border-ds-accent/40 bg-ds-surface-2" : "border-transparent hover:bg-ds-surface-2"
|
||||
}`}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const drill: DrillApi = { extendedKey, openExtended: setExtendedKey, closeExtended: () => setExtendedKey(null) }
|
||||
const detail = selected ? (
|
||||
renderDetail(selected, drill)
|
||||
) : (
|
||||
<p className="py-10 text-center text-[13px] text-ds-text-3">{emptyDetail ?? "Select an item to see its details."}</p>
|
||||
)
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{list}
|
||||
<section aria-live="polite">{detail}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 ${stacked ? "" : "lg:flex-row lg:items-start"}`}>
|
||||
<div className={`flex w-full shrink-0 flex-col gap-2 ${stacked ? "" : listWidth}`}>
|
||||
{listHeader}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex max-h-[360px] flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${maxH}`}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const key = getKey(item)
|
||||
const isSelected = key === selectedKey
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => onSelect(key)}
|
||||
className={`cursor-pointer rounded-[9px] border px-3 py-2 ${
|
||||
isSelected
|
||||
? "border-ds-accent/40 bg-ds-surface-2"
|
||||
: "border-transparent hover:bg-ds-surface-2"
|
||||
}`}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
aria-live="polite"
|
||||
className={`min-w-0 flex-1 ${stacked ? "" : `overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-5 ${maxH}`}`}
|
||||
>
|
||||
{selected ? (
|
||||
renderDetail(selected)
|
||||
) : (
|
||||
<p className="py-10 text-center text-[13px] text-ds-text-3">
|
||||
{emptyDetail ?? "Select an item to see its details."}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ResizablePanes
|
||||
storageKey={storageKey}
|
||||
panes={[
|
||||
{ id: "list", label, node: list, size: listSize, sticky: true },
|
||||
{ id: "detail", label: "Details", node: detail },
|
||||
...(selected && renderExtended && extendedKey
|
||||
? [
|
||||
{
|
||||
id: "extended",
|
||||
label: "Extended details",
|
||||
node: renderExtended(selected, extendedKey),
|
||||
size: extendedSize,
|
||||
sticky: true,
|
||||
title: extendedTitle,
|
||||
onClose: drill.closeExtended,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import {
|
||||
backendRequestsFromTraces,
|
||||
linkRumRequest,
|
||||
parseRequestCrumb,
|
||||
type RumBackendLink,
|
||||
} from "../lib/http-endpoint"
|
||||
import {
|
||||
VITAL_THRESHOLDS,
|
||||
aggregateRum,
|
||||
@@ -162,7 +168,8 @@ const SessionRow = ({
|
||||
}
|
||||
|
||||
const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab, allTraces } = useObservabilityData()
|
||||
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!session) {
|
||||
@@ -174,6 +181,28 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Match each browser request to the backend span that served it. Falls back
|
||||
// to method+path+time when the trace id doesn't survive the hop (e.g. an
|
||||
// uninstrumented nginx in front of the API).
|
||||
const links = new Map<number, RumBackendLink>()
|
||||
session.timeline.forEach((entry, idx) => {
|
||||
if (entry.category !== "fetch" && entry.category !== "xhr") return
|
||||
const req = parseRequestCrumb(entry.message)
|
||||
if (!req) return
|
||||
const link = linkRumRequest(
|
||||
{
|
||||
// Breadcrumbs are stamped on completion; the request started duration earlier.
|
||||
timestamp: entry.timestamp - (entry.durationMs ?? 0),
|
||||
durationMs: entry.durationMs,
|
||||
traceId: entry.traceId,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
},
|
||||
backend,
|
||||
)
|
||||
if (link) links.set(idx, link)
|
||||
})
|
||||
|
||||
const latestTracedError = session.errors.find((e) => e.traceId)
|
||||
|
||||
const copySessionId = () => {
|
||||
@@ -266,6 +295,22 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
<span className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">
|
||||
{entry.message}
|
||||
</span>
|
||||
{links.get(idx) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewTrace(links.get(idx)!.backend.traceId)}
|
||||
className="inline-flex w-fit items-center gap-1 text-[12px] text-ds-accent"
|
||||
title={
|
||||
links.get(idx)!.confidence === "trace-id"
|
||||
? "Linked by trace id"
|
||||
: "Inferred from method, path and timing - no trace id reached the backend"
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
{links.get(idx)!.backend.service || "backend"} trace
|
||||
{links.get(idx)!.confidence === "path+time" && " (inferred)"}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -148,13 +148,14 @@ const getOperationDisplayName = (span: ProcessedSpan) => {
|
||||
};
|
||||
|
||||
// Span Detail Inspector Drawer
|
||||
const SpanInspector = ({
|
||||
export const SpanInspector = ({
|
||||
span,
|
||||
onClose,
|
||||
traceTotalRange,
|
||||
}: {
|
||||
span: ProcessedSpan;
|
||||
onClose: () => void;
|
||||
/** Omit when the host (e.g. a pane) already provides a close control. */
|
||||
onClose?: () => void;
|
||||
traceTotalRange: number;
|
||||
}) => {
|
||||
const [tab, setTab] = useState<"overview" | "attributes" | "events" | "logs">("overview");
|
||||
@@ -219,13 +220,15 @@ const SpanInspector = ({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
|
||||
title="Close Inspector"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
|
||||
title="Close Inspector"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
@@ -678,7 +681,7 @@ const VirtualizedSpanWaterfall = ({
|
||||
{/* Virtualized Span Rows Container */}
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="relative h-[560px] overflow-y-auto divide-y divide-ds-line select-none"
|
||||
className="relative max-h-[560px] overflow-y-auto divide-y divide-ds-line select-none"
|
||||
>
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-sm text-ds-text-3">
|
||||
@@ -823,10 +826,21 @@ const VirtualizedSpanWaterfall = ({
|
||||
// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
|
||||
export const SingleTraceView = ({
|
||||
trace,
|
||||
selectedSpanId: controlledSpanId,
|
||||
onSelectSpan,
|
||||
externalInspector = false,
|
||||
}: {
|
||||
trace: ProcessedTrace;
|
||||
/** Controlled span selection; omit (with onSelectSpan) for the built-in inline inspector. */
|
||||
selectedSpanId?: string | null;
|
||||
onSelectSpan?: (spanId: string | null) => void;
|
||||
/** Don't render the inspector inline: the caller shows it elsewhere (e.g. an extended pane). */
|
||||
externalInspector?: boolean;
|
||||
}) => {
|
||||
const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
|
||||
const [internalSpanId, setInternalSpanId] = useState<string | null>(null);
|
||||
const controlled = onSelectSpan !== undefined;
|
||||
const selectedSpanId = controlled ? (controlledSpanId ?? null) : internalSpanId;
|
||||
const setSelectedSpanId = controlled ? onSelectSpan : setInternalSpanId;
|
||||
const [viewMode, setViewMode] = useState<"waterfall" | "graph">("waterfall");
|
||||
const [filterService, setFilterService] = useState<string | null>(null);
|
||||
|
||||
@@ -867,11 +881,7 @@ export const SingleTraceView = ({
|
||||
return (
|
||||
<section
|
||||
id={trace.id}
|
||||
className={`rounded-3xl border transition-all ${
|
||||
trace.hasError
|
||||
? "border-ds-critical/20 bg-ds-critical-bg"
|
||||
: "border-ds-line bg-ds-surface-2"
|
||||
} p-5 space-y-4`}
|
||||
className="rounded-[14px] border border-ds-line bg-ds-surface p-5 space-y-4"
|
||||
>
|
||||
{/* Trace Card Header */}
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-ds-line pb-4">
|
||||
@@ -1004,17 +1014,17 @@ export const SingleTraceView = ({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
{/* Virtualized Waterfall Table */}
|
||||
<div className={selectedSpan ? "lg:col-span-7 xl:col-span-8" : "col-span-12"}>
|
||||
<div className={selectedSpan && !externalInspector ? "lg:col-span-7 xl:col-span-8" : "col-span-12"}>
|
||||
<VirtualizedSpanWaterfall
|
||||
trace={trace}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelectSpan={(id) => setSelectedSpanId((prev) => (prev === id ? null : id))}
|
||||
onSelectSpan={(id) => setSelectedSpanId(selectedSpanId === id ? null : id)}
|
||||
filterService={filterService}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Span Detail Inspector Panel */}
|
||||
{selectedSpan && (
|
||||
{selectedSpan && !externalInspector && (
|
||||
<div className="lg:col-span-5 xl:col-span-4 sticky top-6">
|
||||
<SpanInspector
|
||||
span={selectedSpan}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Small segmented control for switching between a few mutually exclusive options (sort, range, ...). */
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (v: T) => void
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="inline-flex rounded-[9px] border border-ds-line bg-ds-surface-2 p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={o.value === value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`rounded-[7px] px-2.5 py-1 text-[11.5px] font-semibold ${
|
||||
o.value === value ? "bg-ds-accent-bg text-ds-accent" : "text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Fragment, useCallback, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export interface PaneSize {
|
||||
default: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export interface PaneSpec {
|
||||
/** Stable id: keys the pane and its persisted width. */
|
||||
id: string
|
||||
/** Accessible name for the pane's region. */
|
||||
label: string
|
||||
node: ReactNode
|
||||
/** Fixed-width, user-resizable pane. Omit for the one flexible pane that takes the remaining space. */
|
||||
size?: PaneSize
|
||||
/** Pin the pane while the page scrolls (lists, inspectors). Its content scrolls internally. */
|
||||
sticky?: boolean
|
||||
/** Renders the pane as a titled card with a close button when either is set. */
|
||||
title?: ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const STEP = 16
|
||||
const stickyMax = "lg:max-h-[calc(100vh-190px)]"
|
||||
|
||||
function loadWidths(key: string | undefined): Record<string, number> {
|
||||
if (!key) return {}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`otel.panes.${key}`) ?? "{}") as Record<string, number>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveWidths(key: string | undefined, widths: Record<string, number>) {
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(`otel.panes.${key}`, JSON.stringify(widths))
|
||||
} catch {
|
||||
/* storage unavailable: widths just don't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal stack of panes with draggable dividers, for drill-down screens
|
||||
* (list -> detail -> extended detail). Panes come and go with the `panes`
|
||||
* array, so a third pane simply appears when something is selected.
|
||||
*
|
||||
* - Fixed panes (`size`) are resizable: drag a divider, or focus it and use
|
||||
* the arrow keys; double-click resets to the default. Widths persist per
|
||||
* `storageKey`.
|
||||
* - One pane without `size` is flexible and absorbs the remaining space.
|
||||
* - Below the `lg` breakpoint panes stack vertically and dividers disappear.
|
||||
*/
|
||||
export function ResizablePanes({ panes, storageKey }: { panes: PaneSpec[]; storageKey?: string }) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>(() => loadWidths(storageKey))
|
||||
const drag = useRef<{ index: number; x: number; start: number } | null>(null)
|
||||
|
||||
const widthOf = (p: PaneSpec) => (p.size ? clamp(widths[p.id] ?? p.size.default, p.size) : 0)
|
||||
|
||||
/** The fixed pane a divider before `index` controls, and the direction dx moves it. */
|
||||
const target = (index: number): { pane: PaneSpec; sign: 1 | -1 } | null => {
|
||||
const left = panes[index - 1]
|
||||
const right = panes[index]
|
||||
if (left?.size) return { pane: left, sign: 1 }
|
||||
if (right?.size) return { pane: right, sign: -1 }
|
||||
return null
|
||||
}
|
||||
|
||||
const setWidth = useCallback(
|
||||
(pane: PaneSpec, px: number, persist: boolean) => {
|
||||
setWidths((prev) => {
|
||||
const next = { ...prev, [pane.id]: clamp(px, pane.size!) }
|
||||
if (persist) saveWidths(storageKey, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
const onPointerDown = (index: number) => (e: PointerEvent<HTMLDivElement>) => {
|
||||
const t = target(index)
|
||||
if (!t) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { index, x: e.clientX, start: widthOf(t.pane) }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current
|
||||
const t = d && target(d.index)
|
||||
if (!d || !t) return
|
||||
setWidth(t.pane, d.start + t.sign * (e.clientX - d.x), false)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (drag.current) saveWidths(storageKey, widths)
|
||||
drag.current = null
|
||||
}
|
||||
|
||||
const onKeyDown = (index: number) => (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const t = target(index)
|
||||
if (!t) return
|
||||
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0
|
||||
if (!dir) return
|
||||
e.preventDefault()
|
||||
setWidth(t.pane, widthOf(t.pane) + dir * t.sign * STEP, true)
|
||||
}
|
||||
|
||||
const reset = (index: number) => {
|
||||
const t = target(index)
|
||||
if (t) setWidth(t.pane, t.pane.size!.default, true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-0">
|
||||
{panes.map((p, i) => {
|
||||
const t = i > 0 ? target(i) : null
|
||||
const boxed = Boolean(p.title || p.onClose)
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${p.label}`}
|
||||
aria-valuenow={t ? widthOf(t.pane) : undefined}
|
||||
tabIndex={t ? 0 : -1}
|
||||
onPointerDown={onPointerDown(i)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onKeyDown={onKeyDown(i)}
|
||||
onDoubleClick={() => reset(i)}
|
||||
className="group hidden w-3 shrink-0 cursor-col-resize touch-none justify-center self-stretch focus:outline-none lg:flex"
|
||||
>
|
||||
<span className="w-px bg-transparent group-hover:bg-ds-line-strong group-focus-visible:bg-ds-accent" />
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
aria-label={p.label}
|
||||
style={p.size ? ({ "--pane-w": `${widthOf(p)}px` } as React.CSSProperties) : undefined}
|
||||
className={`min-w-0 ${p.size ? "w-full lg:w-[var(--pane-w)] lg:shrink-0" : "w-full flex-1"} ${
|
||||
p.sticky ? "lg:sticky lg:top-4 lg:self-start" : ""
|
||||
}`}
|
||||
>
|
||||
{boxed ? (
|
||||
<div className={`flex flex-col overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface ${p.sticky ? stickyMax : ""}`}>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-ds-line px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{p.title}</span>
|
||||
{p.onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onClose}
|
||||
aria-label={`Close ${p.label}`}
|
||||
className="rounded 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>
|
||||
)}
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">{p.node}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={p.sticky ? `${stickyMax} lg:overflow-auto` : ""}>{p.node}</div>
|
||||
)}
|
||||
</section>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(n: number, s: PaneSize) {
|
||||
return Math.min(s.max, Math.max(s.min, n))
|
||||
}
|
||||
@@ -10,12 +10,17 @@ export const TraceDetail = ({
|
||||
loading,
|
||||
spanId,
|
||||
label = "Selected trace",
|
||||
selectedSpanId,
|
||||
onSelectSpan,
|
||||
}: {
|
||||
traceId: string
|
||||
trace?: ProcessedTrace
|
||||
loading?: boolean
|
||||
spanId?: string
|
||||
label?: string
|
||||
/** Controlled span selection: the inspector is then expected to render elsewhere (extended pane). */
|
||||
selectedSpanId?: string | null
|
||||
onSelectSpan?: (spanId: string | null) => void
|
||||
}) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const open = () => {
|
||||
@@ -37,7 +42,13 @@ export const TraceDetail = ({
|
||||
</button>
|
||||
</div>
|
||||
{trace ? (
|
||||
<SingleTraceView key={trace.id} trace={trace} />
|
||||
<SingleTraceView
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelectSpan={onSelectSpan}
|
||||
externalInspector={onSelectSpan !== undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-[14px] border border-dashed border-ds-line py-10 text-center text-[13px] text-ds-text-3">
|
||||
{loading ? "Loading trace…" : "Trace spans are no longer available."}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatDuration, formatRelativeTime } from "../../lib/timeUtils"
|
||||
import type { TraceSummary } from "../../lib/types"
|
||||
|
||||
/**
|
||||
* Compact trace card for narrow master lists: age and duration up top, name,
|
||||
* a duration bar scaled against the set, then span/error/service counts.
|
||||
* Colour is used only for real state (errors present).
|
||||
*/
|
||||
export const TraceListItem = ({
|
||||
summary,
|
||||
traceId,
|
||||
timestampNs,
|
||||
fallbackTitle,
|
||||
maxDuration,
|
||||
hits,
|
||||
}: {
|
||||
summary?: TraceSummary
|
||||
traceId?: string
|
||||
timestampNs: number
|
||||
fallbackTitle: string
|
||||
maxDuration: number
|
||||
hits?: number
|
||||
}) => {
|
||||
const errored = (summary?.errorCount ?? 0) > 0
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[11.5px] text-ds-text-3">{formatRelativeTime(timestampNs / 1e6)}</span>
|
||||
{summary && (
|
||||
<span className={`font-[family-name:var(--font-data)] text-[12px] font-medium ${errored ? "text-ds-critical" : "text-ds-text-2"}`}>
|
||||
{formatDuration(summary.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-[13px] text-ds-text">{summary?.name ?? fallbackTitle}</span>
|
||||
{summary && (
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-ds-fill">
|
||||
<span
|
||||
className={`block h-full rounded-full ${errored ? "bg-ds-critical" : "bg-ds-accent"}`}
|
||||
style={{ width: `${Math.max(2, (summary.duration / maxDuration) * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||||
<span className="truncate">
|
||||
{summary
|
||||
? `${summary.spanCount} spans · ${summary.services.length} svc${errored ? ` · ${summary.errorCount} err` : ""}`
|
||||
: traceId
|
||||
? `trace:${traceId.slice(0, 8)}`
|
||||
: "no trace attached"}
|
||||
</span>
|
||||
{hits !== undefined && hits > 1 && <span className="shrink-0">×{hits}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -746,6 +746,7 @@ export function ObservabilityProvider({
|
||||
logs: filteredLogs,
|
||||
metrics: filteredMetrics,
|
||||
traces: filteredTraces,
|
||||
allTraces: traces,
|
||||
unfocusedLogs,
|
||||
unfocusedTraces,
|
||||
logsByTraceId,
|
||||
|
||||
@@ -29,6 +29,8 @@ interface ObservabilityContextType {
|
||||
logs: LogEntry[];
|
||||
metrics: MetricData[];
|
||||
traces: TraceData[];
|
||||
/** Every loaded trace, ignoring the active filters (used for cross-signal linking). */
|
||||
allTraces: TraceData[];
|
||||
unfocusedLogs: LogEntry[];
|
||||
unfocusedTraces: TraceData[];
|
||||
logsByTraceId: Record<string, LogEntry[]>;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { getAttributeValue } from "./otel-utils"
|
||||
import type { TraceData } from "./types"
|
||||
|
||||
/**
|
||||
* Normalizes the many attribute spellings for "which HTTP endpoint" (stable
|
||||
* semconv, legacy semconv, nginx, Go, browser RUM) into one shape. Mirrors
|
||||
* internal/httpattr in the Go backend - keep the two in sync.
|
||||
*/
|
||||
|
||||
export interface HttpEndpoint {
|
||||
method: string
|
||||
host: string
|
||||
path: string
|
||||
route: string
|
||||
}
|
||||
|
||||
type Get = (...keys: string[]) => string
|
||||
|
||||
export function cleanPath(p: string): string {
|
||||
p = p.split(/[?#]/)[0].trim()
|
||||
if (!p) return ""
|
||||
if (!p.startsWith("/")) p = "/" + p
|
||||
return p.length > 1 ? p.replace(/\/+$/, "") || "/" : p
|
||||
}
|
||||
|
||||
function stripPort(host: string): string {
|
||||
return host.replace(/:\d+$/, "")
|
||||
}
|
||||
|
||||
export function splitUrl(raw: string): { host: string; path: string } {
|
||||
try {
|
||||
const u = new URL(raw, "http://relative.invalid")
|
||||
const host = u.host === "relative.invalid" ? "" : stripPort(u.host.toLowerCase())
|
||||
return { host, path: cleanPath(u.pathname) }
|
||||
} catch {
|
||||
return { host: "", path: cleanPath(raw) }
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEndpoint(get: Get): HttpEndpoint {
|
||||
const e: HttpEndpoint = {
|
||||
method: get("http.request.method", "http.method").toUpperCase(),
|
||||
route: get("http.route"),
|
||||
host: stripPort(get("server.address", "http.host", "net.host.name", "server.host").toLowerCase()),
|
||||
path: cleanPath(get("url.path") || get("http.target")),
|
||||
}
|
||||
const full = get("url.full", "http.url")
|
||||
if (full) {
|
||||
const u = splitUrl(full)
|
||||
e.path ||= u.path
|
||||
e.host ||= u.host
|
||||
}
|
||||
if (e.route === "/" && e.path && e.path !== "/") e.route = ""
|
||||
return e
|
||||
}
|
||||
|
||||
/** Removes a reverse-proxy mount prefix; longest match wins. */
|
||||
export function stripPrefix(path: string, prefixes: string[]): string {
|
||||
let best = ""
|
||||
for (const raw of prefixes) {
|
||||
const p = cleanPath(raw)
|
||||
if (p && p !== "/" && (path === p || path.startsWith(p + "/")) && p.length > best.length) best = p
|
||||
}
|
||||
return best ? path.slice(best.length) || "/" : path
|
||||
}
|
||||
|
||||
export interface BackendRequest {
|
||||
traceId: string
|
||||
spanId: string
|
||||
service: string
|
||||
startMs: number
|
||||
durationMs: number
|
||||
endpoint: HttpEndpoint
|
||||
}
|
||||
|
||||
export interface RumRequest {
|
||||
timestamp: number
|
||||
traceId?: string
|
||||
method?: string
|
||||
url?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export type LinkConfidence = "trace-id" | "path+time"
|
||||
|
||||
export interface RumBackendLink {
|
||||
backend: BackendRequest
|
||||
confidence: LinkConfidence
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
/** Proxy mount prefixes the browser sees but the backend does not, e.g. ["/api"]. */
|
||||
stripPrefixes?: string[]
|
||||
/** How far a backend span start may be from the browser request, in ms. */
|
||||
windowMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the backend span a browser request hit. Exact trace id wins; when the
|
||||
* hop in between (e.g. an uninstrumented nginx that neither propagates
|
||||
* traceparent nor emits spans) breaks the trace, fall back to method + path
|
||||
* inside a time window. Returns null unless exactly one candidate is closest,
|
||||
* so an ambiguous match is never presented as a link.
|
||||
*/
|
||||
export function linkRumRequest(
|
||||
req: RumRequest,
|
||||
backend: BackendRequest[],
|
||||
{ stripPrefixes = [], windowMs = 2000 }: LinkOptions = {},
|
||||
): RumBackendLink | null {
|
||||
if (req.traceId) {
|
||||
const hit = backend.find((b) => b.traceId === req.traceId)
|
||||
if (hit) return { backend: hit, confidence: "trace-id" }
|
||||
}
|
||||
if (!req.url) return null
|
||||
const { path } = splitUrl(req.url)
|
||||
const method = (req.method || "GET").toUpperCase()
|
||||
const wanted = new Set([path, stripPrefix(path, stripPrefixes)])
|
||||
// The request can only reach the server after the browser started it, and
|
||||
// must have started before the browser saw the response.
|
||||
const end = req.timestamp + (req.durationMs ?? 0)
|
||||
const candidates = backend
|
||||
.filter((b) => b.endpoint.method === method && wanted.has(b.endpoint.path))
|
||||
.filter((b) => b.startMs >= req.timestamp - windowMs && b.startMs <= Math.max(end, req.timestamp) + windowMs)
|
||||
.map((b) => ({ b, d: Math.abs(b.startMs - req.timestamp) }))
|
||||
.sort((x, y) => x.d - y.d)
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length > 1 && candidates[0].d === candidates[1].d) return null
|
||||
return { backend: candidates[0].b, confidence: "path+time" }
|
||||
}
|
||||
|
||||
/** Server-side HTTP spans (kind SERVER) from loaded traces, normalized for linking. */
|
||||
export function backendRequestsFromTraces(traces: TraceData[]): BackendRequest[] {
|
||||
const out: BackendRequest[] = []
|
||||
for (const trace of traces) {
|
||||
for (const rs of trace.resourceSpans ?? []) {
|
||||
const service = getAttributeValue(rs.resource?.attributes ?? [], "service.name")
|
||||
for (const ss of rs.scopeSpans ?? []) {
|
||||
for (const s of ss.spans ?? []) {
|
||||
if (s.kind !== 2) continue
|
||||
const endpoint = extractEndpoint((...keys) => {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(s.attributes ?? [], k)
|
||||
if (v) return v
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if (!endpoint.path) continue
|
||||
const start = Number(BigInt(s.startTimeUnixNano) / 1_000_000n)
|
||||
const end = Number(BigInt(s.endTimeUnixNano) / 1_000_000n)
|
||||
out.push({ traceId: s.traceId, spanId: s.spanId, service, startMs: start, durationMs: end - start, endpoint })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parses an otel-rum.js fetch/xhr breadcrumb: "GET /api/x -> 200". */
|
||||
export function parseRequestCrumb(message: string): { method: string; url: string } | null {
|
||||
const m = /^([A-Z]+)\s+(\S+)\s+->\s+\S+$/.exec(message)
|
||||
return m ? { method: m[1], url: m[2] } : null
|
||||
}
|
||||
@@ -35,6 +35,10 @@ export interface ParsedBreadcrumb {
|
||||
t: number
|
||||
category: string
|
||||
message: string
|
||||
/** Request duration in ms (fetch/xhr crumbs); the request started at t - d. */
|
||||
d?: number
|
||||
/** Trace id otel-rum.js sent on the request, if it injected traceparent. */
|
||||
tid?: string
|
||||
}
|
||||
|
||||
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface RumTimelineEntry {
|
||||
category: "session-start" | "navigation" | "fetch" | "xhr" | "console.error" | "js-error" | "http-failure"
|
||||
message: string
|
||||
severe: boolean
|
||||
durationMs?: number
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
@@ -273,6 +275,8 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] {
|
||||
: "navigation",
|
||||
message: crumb.message,
|
||||
severe: crumb.category === "console.error" || isFailedRequestBreadcrumb(crumb.message),
|
||||
durationMs: typeof crumb.d === "number" ? crumb.d : undefined,
|
||||
traceId: crumb.tid || undefined,
|
||||
})
|
||||
}
|
||||
for (const err of errors) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const target = "https://otel.k6n.net";
|
||||
const target = process.env.OTEL_URL ?? "https://otel.k6n.net";
|
||||
// Device-grant issuer for local/agent development (see src/dev/device-auth.ts).
|
||||
// Proxied same-origin so the browser needs no CORS and no issuer URL.
|
||||
const authIssuer = process.env.AUTH_DEV_BASE ?? "https://users.k6n.net";
|
||||
|
||||
Reference in new issue
Block a user