materialized view and a bit better ui
This commit is contained in:
1 parent
26b677154b
commit
49f9bb3458
30 files changed
+2184
-106
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { MasterDetail } from "./MasterDetail"
|
||||
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 { OccurrenceDetail } from "./issues/OccurrenceDetail"
|
||||
import { useIssueTraces } from "../hooks/useIssueTraces"
|
||||
import { useProcessedTraces } from "../hooks/useProcessedTraces"
|
||||
import { formatDuration } from "../lib/timeUtils"
|
||||
import type { Issue, IssueSample, TraceSummary } from "../lib/types"
|
||||
|
||||
/** One selectable row: a trace with its occurrences, or a lone traceless occurrence. */
|
||||
interface Row {
|
||||
key: string
|
||||
traceId?: string
|
||||
samples: IssueSample[]
|
||||
ts: number
|
||||
hits: number
|
||||
summary?: TraceSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const IssueOccurrences = ({
|
||||
issue,
|
||||
initialTraceId,
|
||||
onBack,
|
||||
}: {
|
||||
issue: Issue
|
||||
initialTraceId?: string
|
||||
onBack: () => void
|
||||
}) => {
|
||||
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const traced = traces.map((t) => ({
|
||||
key: t.traceId,
|
||||
traceId: t.traceId,
|
||||
samples: t.samples,
|
||||
ts: t.firstSeen,
|
||||
hits: t.hits,
|
||||
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 [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
||||
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||
const selectedTraceId = selected?.traceId
|
||||
|
||||
// Spans are fetched only for the selected trace.
|
||||
const { byId, loading: spansLoading, failed } = useProcessedTraces(selectedTraceId ? [selectedTraceId] : [])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const sums = traces.flatMap((t) => (t.summary ? [t.summary] : []))
|
||||
return {
|
||||
maxDuration: Math.max(1, ...sums.map((s) => s.duration)),
|
||||
slowest: Math.max(0, ...sums.map((s) => s.duration)),
|
||||
errored: sums.filter((s) => s.errorCount > 0).length,
|
||||
services: new Set(sums.flatMap((s) => s.services)).size,
|
||||
known: sums.length > 0,
|
||||
}
|
||||
}, [traces])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<StatHeader
|
||||
back={<BackButton label="All issues" onClick={onBack} />}
|
||||
eyebrow={`service=${issue.service}`}
|
||||
title={issue.title}
|
||||
stats={
|
||||
<>
|
||||
<Stat label="Traces" value={`${traces.length}${hasMore ? "+" : ""}`} />
|
||||
<Stat label="Occurrences" value={total || issue.count} />
|
||||
<Stat label="Slowest" value={stats.known ? formatDuration(stats.slowest) : "–"} />
|
||||
<Stat label="With errors" value={stats.known ? stats.errored : "–"} tone={stats.errored ? "critical" : undefined} />
|
||||
<Stat label="Services" value={stats.known ? stats.services : "–"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <p className="text-[12px] text-ds-critical">Failed to load traces: {error}</p>}
|
||||
{!complete && (
|
||||
<p className="text-[12px] text-ds-text-3">
|
||||
Showing only the {issue.samples.length} most recent occurrences; full history needs ClickHouse storage.
|
||||
</p>
|
||||
)}
|
||||
{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 />}
|
||||
renderItem={(r) => (
|
||||
<TraceOverviewRow
|
||||
summary={r.summary}
|
||||
traceId={r.traceId}
|
||||
timestampNs={r.ts}
|
||||
fallbackTitle={r.samples[0]?.message ?? "occurrence"}
|
||||
maxDuration={stats.maxDuration}
|
||||
hits={r.hits}
|
||||
/>
|
||||
)}
|
||||
renderDetail={(r) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{r.traceId && (
|
||||
<TraceDetail
|
||||
traceId={r.traceId}
|
||||
trace={byId.get(r.traceId)}
|
||||
loading={spansLoading && !failed.has(r.traceId)}
|
||||
spanId={r.samples[0]?.spanId}
|
||||
/>
|
||||
)}
|
||||
<SectionLabel>
|
||||
Occurrences in this {r.traceId ? "trace" : "record"} ({r.hits}
|
||||
{r.samples.length < r.hits ? `, latest ${r.samples.length} shown` : ""})
|
||||
</SectionLabel>
|
||||
{r.samples.map((s, i) => (
|
||||
<OccurrenceDetail key={`${s.timestamp}-${i}`} sample={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="self-center rounded-[9px] border border-ds-line-strong px-4 py-2 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading…" : "Load older traces"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IssueOccurrences
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
strongestCorrelate,
|
||||
type SeverityTone,
|
||||
} from "../lib/issue-utils"
|
||||
import { IssueOccurrences } from "./IssueOccurrences"
|
||||
import { NeedleTestModal } from "./NeedleTestModal"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
import type { Issue, IssueSample } from "../lib/types"
|
||||
@@ -277,12 +278,13 @@ const IssueCard = ({
|
||||
issue,
|
||||
onStatusChange,
|
||||
statusBusy,
|
||||
onFocus,
|
||||
}: {
|
||||
issue: Issue
|
||||
onStatusChange: (issue: Issue, status: string) => void
|
||||
statusBusy: boolean
|
||||
onFocus: (issue: Issue, traceId?: string) => void
|
||||
}) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
||||
const [needleLoading, setNeedleLoading] = useState(false)
|
||||
@@ -311,11 +313,7 @@ const IssueCard = ({
|
||||
const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus)
|
||||
const correlate = strongestCorrelate(issue)
|
||||
|
||||
const viewTrace = (traceId: string, spanId?: string) => {
|
||||
setActiveTraceId(traceId)
|
||||
if (spanId) setActiveSpanId(spanId)
|
||||
setActiveTab("tab1")
|
||||
}
|
||||
const viewTrace = (traceId: string) => onFocus(issue, traceId)
|
||||
|
||||
const metaParts = [`service=${issue.service}`]
|
||||
if (contextSample?.httpMethod && contextSample?.httpRoute) {
|
||||
@@ -360,7 +358,7 @@ const IssueCard = ({
|
||||
{latestSample?.traceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
|
||||
onClick={() => viewTrace(latestSample.traceId!)}
|
||||
className="inline-flex h-9 items-center rounded-[9px] bg-ds-accent px-3.5 text-[13px] font-semibold text-ds-on-accent"
|
||||
>
|
||||
View trace
|
||||
@@ -534,7 +532,7 @@ const IssueCard = ({
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
viewTrace(sample.traceId!, sample.spanId)
|
||||
viewTrace(sample.traceId!)
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full border border-ds-line-strong px-2.5 py-0.5 text-[10.5px] font-bold text-ds-text-2 hover:text-ds-text"
|
||||
>
|
||||
@@ -655,6 +653,8 @@ 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 })
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||||
@@ -813,6 +813,18 @@ export const IssuesVisualizer = ({
|
||||
)
|
||||
}
|
||||
|
||||
const focusedIssue = focus ? issues.find((i) => i.id === focus.issueId) : undefined
|
||||
if (focus && focusedIssue) {
|
||||
return (
|
||||
<IssueOccurrences
|
||||
key={focus.issueId}
|
||||
issue={focusedIssue}
|
||||
initialTraceId={focus.traceId}
|
||||
onBack={() => setFocus(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
@@ -993,7 +1005,7 @@ export const IssuesVisualizer = ({
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{primaryIssues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1015,7 +1027,7 @@ export const IssuesVisualizer = ({
|
||||
{showSecondary && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{secondaryIssues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
|
||||
|
||||
interface MasterDetailProps<T> {
|
||||
items: T[]
|
||||
getKey: (item: T) => string
|
||||
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". */
|
||||
label: string
|
||||
/** Optional heading 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
*/
|
||||
export function MasterDetail<T>({
|
||||
items,
|
||||
getKey,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderDetail,
|
||||
label,
|
||||
listHeader,
|
||||
emptyDetail,
|
||||
layout = "split",
|
||||
listWidth = "lg:w-[380px]",
|
||||
maxHeight,
|
||||
}: 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 selected = items.find((item) => getKey(item) === selectedKey) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedKey])
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default MasterDetail
|
||||
@@ -821,7 +821,7 @@ const VirtualizedSpanWaterfall = ({
|
||||
};
|
||||
|
||||
// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
|
||||
const SingleTraceView = ({
|
||||
export const SingleTraceView = ({
|
||||
trace,
|
||||
}: {
|
||||
trace: ProcessedTrace;
|
||||
@@ -1542,6 +1542,98 @@ const TraceDirectoryTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
/** Group raw OTLP trace payloads into per-trace ProcessedTraces, most recent first. */
|
||||
export const processTraces = (
|
||||
safeTraces: TraceData[],
|
||||
logCountBySpanId: Record<string, number>,
|
||||
): ProcessedTrace[] => {
|
||||
const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) =>
|
||||
trace.resourceSpans?.flatMap((resourceSpan) =>
|
||||
resourceSpan.scopeSpans?.flatMap((scopeSpan) =>
|
||||
scopeSpan.spans?.map((spanItem) => {
|
||||
const { attributes, ...spanDetails } = spanItem;
|
||||
const formattedAttributes = Object.fromEntries(
|
||||
attributes?.map(({ key, value }) => [key, formatAttributeValue(value)]) ?? [],
|
||||
);
|
||||
const serviceName =
|
||||
resourceSpan.resource?.attributes?.find(
|
||||
(attr) => attr.key === "service.name",
|
||||
)?.value.stringValue || "unknown-service";
|
||||
|
||||
const startTime = Number(spanDetails.startTimeUnixNano ?? "0");
|
||||
const endTime = Number(
|
||||
spanDetails.endTimeUnixNano ?? spanDetails.startTimeUnixNano ?? "0",
|
||||
);
|
||||
const duration = Math.max(endTime - startTime, 0);
|
||||
|
||||
const statusCode = formattedAttributes["http.status_code"];
|
||||
const isHttpError = Boolean(statusCode && Number(statusCode) >= 400);
|
||||
const isOtelError = (spanDetails.status as any)?.code === 2;
|
||||
const hasError = isHttpError || isOtelError;
|
||||
|
||||
const logCount = logCountBySpanId[spanDetails.spanId] || 0;
|
||||
|
||||
return {
|
||||
...spanDetails,
|
||||
attributes,
|
||||
formattedAttributes,
|
||||
service: serviceName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
logCount,
|
||||
hasError,
|
||||
} as ProcessedSpan;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const uniqueSpans = spanAccumulator.filter(
|
||||
(span, index, arr) =>
|
||||
arr.findIndex((candidate) => candidate.spanId === span.spanId) === index,
|
||||
);
|
||||
|
||||
const traceGroups = uniqueSpans.reduce<Record<string, ProcessedSpan[]>>((acc, span) => {
|
||||
if (!acc[span.traceId]) acc[span.traceId] = [];
|
||||
acc[span.traceId].push(span);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const traceList: ProcessedTrace[] = Object.entries(traceGroups).map(([id, spans]) => {
|
||||
const orderedSpans = spans.sort((a, b) => a.startTime - b.startTime);
|
||||
const displayName =
|
||||
orderedSpans.find((span) => span.name)?.name || `Trace ${id.slice(0, 8)}`;
|
||||
const startTime = orderedSpans.reduce(
|
||||
(min, s) => Math.min(min, s.startTime),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const endTime = orderedSpans.reduce(
|
||||
(max, s) => Math.max(max, s.endTime),
|
||||
Number.NEGATIVE_INFINITY,
|
||||
);
|
||||
const duration = Math.max(endTime - startTime, 0);
|
||||
const services = Array.from(new Set(orderedSpans.map((s) => s.service)));
|
||||
const errorCount = orderedSpans.filter((s) => s.hasError).length;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: displayName,
|
||||
spans: orderedSpans,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
services,
|
||||
hasError: errorCount > 0,
|
||||
errorCount,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort traces by most recent first
|
||||
traceList.sort((a, b) => b.startTime - a.startTime);
|
||||
return traceList;
|
||||
};
|
||||
|
||||
export interface TraceVisualizationProps {
|
||||
traces: TraceData[] | undefined;
|
||||
}
|
||||
@@ -1564,93 +1656,10 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
|
||||
"recent" | "duration-desc" | "duration-asc" | "errors" | "spans"
|
||||
>("recent");
|
||||
|
||||
const orderedTraces: ProcessedTrace[] = useMemo(() => {
|
||||
const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) =>
|
||||
trace.resourceSpans?.flatMap((resourceSpan) =>
|
||||
resourceSpan.scopeSpans?.flatMap((scopeSpan) =>
|
||||
scopeSpan.spans?.map((spanItem) => {
|
||||
const { attributes, ...spanDetails } = spanItem;
|
||||
const formattedAttributes = Object.fromEntries(
|
||||
attributes?.map(({ key, value }) => [key, formatAttributeValue(value)]) ?? [],
|
||||
);
|
||||
const serviceName =
|
||||
resourceSpan.resource?.attributes?.find(
|
||||
(attr) => attr.key === "service.name",
|
||||
)?.value.stringValue || "unknown-service";
|
||||
|
||||
const startTime = Number(spanDetails.startTimeUnixNano ?? "0");
|
||||
const endTime = Number(
|
||||
spanDetails.endTimeUnixNano ?? spanDetails.startTimeUnixNano ?? "0",
|
||||
);
|
||||
const duration = Math.max(endTime - startTime, 0);
|
||||
|
||||
const statusCode = formattedAttributes["http.status_code"];
|
||||
const isHttpError = Boolean(statusCode && Number(statusCode) >= 400);
|
||||
const isOtelError = (spanDetails.status as any)?.code === 2;
|
||||
const hasError = isHttpError || isOtelError;
|
||||
|
||||
const logCount = logCountBySpanId[spanDetails.spanId] || 0;
|
||||
|
||||
return {
|
||||
...spanDetails,
|
||||
attributes,
|
||||
formattedAttributes,
|
||||
service: serviceName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
logCount,
|
||||
hasError,
|
||||
} as ProcessedSpan;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const uniqueSpans = spanAccumulator.filter(
|
||||
(span, index, arr) =>
|
||||
arr.findIndex((candidate) => candidate.spanId === span.spanId) === index,
|
||||
);
|
||||
|
||||
const traceGroups = uniqueSpans.reduce<Record<string, ProcessedSpan[]>>((acc, span) => {
|
||||
if (!acc[span.traceId]) acc[span.traceId] = [];
|
||||
acc[span.traceId].push(span);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const traceList: ProcessedTrace[] = Object.entries(traceGroups).map(([id, spans]) => {
|
||||
const orderedSpans = spans.sort((a, b) => a.startTime - b.startTime);
|
||||
const displayName =
|
||||
orderedSpans.find((span) => span.name)?.name || `Trace ${id.slice(0, 8)}`;
|
||||
const startTime = orderedSpans.reduce(
|
||||
(min, s) => Math.min(min, s.startTime),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const endTime = orderedSpans.reduce(
|
||||
(max, s) => Math.max(max, s.endTime),
|
||||
Number.NEGATIVE_INFINITY,
|
||||
);
|
||||
const duration = Math.max(endTime - startTime, 0);
|
||||
const services = Array.from(new Set(orderedSpans.map((s) => s.service)));
|
||||
const errorCount = orderedSpans.filter((s) => s.hasError).length;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: displayName,
|
||||
spans: orderedSpans,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
services,
|
||||
hasError: errorCount > 0,
|
||||
errorCount,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort traces by most recent first
|
||||
traceList.sort((a, b) => b.startTime - a.startTime);
|
||||
return traceList;
|
||||
}, [safeTraces, logCountBySpanId]);
|
||||
const orderedTraces: ProcessedTrace[] = useMemo(
|
||||
() => processTraces(safeTraces, logCountBySpanId),
|
||||
[safeTraces, logCountBySpanId],
|
||||
);
|
||||
|
||||
const allServices = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export const BackButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-9 items-center gap-1.5 self-start rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Label/value pair for a <dl>; renders nothing for empty values. */
|
||||
export const DetailField = ({ label, value }: { label: string; value?: string | number }) =>
|
||||
value === undefined || value === "" ? null : (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{label}</dt>
|
||||
<dd className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{children}</p>
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
export const Stat = ({ label, value, tone }: { label: string; value: string | number; tone?: "critical" }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{label}</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[20px] font-bold ${tone === "critical" ? "text-ds-critical" : "text-ds-text"}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Header strip: optional back button, title block, then stats on the right. */
|
||||
export const StatHeader = ({
|
||||
eyebrow,
|
||||
title,
|
||||
back,
|
||||
stats,
|
||||
}: {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
back?: React.ReactNode
|
||||
stats: React.ReactNode
|
||||
}) => (
|
||||
<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>}
|
||||
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{stats}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DetailField } from "../common/DetailField"
|
||||
import { formatRelativeTime } from "../../lib/timeUtils"
|
||||
import { parseBreadcrumbs } from "../../lib/issue-utils"
|
||||
import type { IssueSample } from "../../lib/types"
|
||||
|
||||
export const OccurrenceDetail = ({ sample }: { sample: IssueSample }) => {
|
||||
const stack = sample.resolvedStacktrace || sample.stacktrace
|
||||
const crumbs = parseBreadcrumbs(sample.breadcrumbs)
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-[10px] border border-ds-line bg-ds-surface-2 p-4">
|
||||
<div>
|
||||
<p className="text-[12px] text-ds-text-3">
|
||||
{new Date(sample.timestamp / 1e6).toLocaleString()} · {formatRelativeTime(sample.timestamp / 1e6)}
|
||||
</p>
|
||||
<p className="mt-1 break-all text-[13.5px] text-ds-text">{sample.message}</p>
|
||||
</div>
|
||||
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<DetailField label="Route" value={[sample.httpMethod, sample.httpRoute].filter(Boolean).join(" ")} />
|
||||
<DetailField label="Status" value={sample.httpStatus} />
|
||||
<DetailField label="Release" value={sample.release} />
|
||||
<DetailField label="Environment" value={sample.environment} />
|
||||
<DetailField label="Span" value={sample.spanId} />
|
||||
<DetailField label="Source IP" value={sample.sourceIp} />
|
||||
<DetailField label="User agent" value={sample.userAgent} />
|
||||
</dl>
|
||||
{sample.url && (
|
||||
<a href={sample.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 break-all text-[12px] text-ds-accent">
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{sample.url}
|
||||
</a>
|
||||
)}
|
||||
{stack && (
|
||||
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">
|
||||
{stack}
|
||||
</pre>
|
||||
)}
|
||||
{crumbs.length > 0 && (
|
||||
<div className="max-h-44 space-y-1 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3">
|
||||
{crumbs.map((c, i) => (
|
||||
<div key={`${c.t}-${i}`} className="flex items-baseline gap-2 font-[family-name:var(--font-data)] text-[11px]">
|
||||
<span className="shrink-0 text-ds-text-3">{new Date(c.t).toLocaleTimeString([], { hour12: false })}</span>
|
||||
<span className="shrink-0 rounded bg-ds-fill px-1.5 py-0.5 text-[10px] font-bold uppercase text-ds-text-2">{c.category}</span>
|
||||
<span className="min-w-0 break-all text-ds-text-2">{c.message.slice(0, 200)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Activity } from "lucide-react"
|
||||
import { SingleTraceView, type ProcessedTrace } from "../TraceVisualization"
|
||||
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||
import { SectionLabel } from "../common/DetailField"
|
||||
|
||||
/** Waterfall + span inspector for one trace, with an "Open in Traces" jump. Handles the not-yet-loaded state. */
|
||||
export const TraceDetail = ({
|
||||
traceId,
|
||||
trace,
|
||||
loading,
|
||||
spanId,
|
||||
label = "Selected trace",
|
||||
}: {
|
||||
traceId: string
|
||||
trace?: ProcessedTrace
|
||||
loading?: boolean
|
||||
spanId?: string
|
||||
label?: string
|
||||
}) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const open = () => {
|
||||
setActiveTraceId(traceId)
|
||||
if (spanId) setActiveSpanId(spanId)
|
||||
setActiveTab("tab1")
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Open in Traces
|
||||
</button>
|
||||
</div>
|
||||
{trace ? (
|
||||
<SingleTraceView key={trace.id} trace={trace} />
|
||||
) : (
|
||||
<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."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import type { TraceSummary } from "../../lib/types"
|
||||
import { formatDuration } from "../../lib/timeUtils"
|
||||
|
||||
const GRID =
|
||||
"grid grid-cols-[84px_minmax(0,1.6fr)_minmax(140px,1fr)_56px_minmax(0,1fr)_64px_64px] items-center gap-3"
|
||||
|
||||
export const clockTime = (ns: number) =>
|
||||
new Date(ns / 1e6).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||
|
||||
/** Column header matching TraceOverviewRow. Pass as MasterDetail's listHeader. */
|
||||
export const TraceOverviewHeader = ({ hitsLabel = "Hits" }: { hitsLabel?: string }) => (
|
||||
<div className={`${GRID} px-4 text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3`}>
|
||||
<span>Time</span>
|
||||
<span>Trace</span>
|
||||
<span>Duration</span>
|
||||
<span className="text-right">Spans</span>
|
||||
<span>Services</span>
|
||||
<span className="text-right">Errors</span>
|
||||
<span className="text-right">{hitsLabel}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* One trace in an overview table, rendered from a server-side TraceSummary
|
||||
* (no spans needed). `summary` may be undefined for traces the server has no
|
||||
* rollup for; `fallbackTitle` and `traceId` keep the row useful then.
|
||||
* `maxDuration` scales the duration bar across the visible set.
|
||||
*/
|
||||
export const TraceOverviewRow = ({
|
||||
summary: trace,
|
||||
traceId,
|
||||
timestampNs,
|
||||
fallbackTitle,
|
||||
maxDuration,
|
||||
hits,
|
||||
loading,
|
||||
}: {
|
||||
summary?: TraceSummary
|
||||
traceId?: string
|
||||
timestampNs: number
|
||||
fallbackTitle: string
|
||||
maxDuration: number
|
||||
hits?: number
|
||||
loading?: boolean
|
||||
}) => (
|
||||
<div className={`${GRID} text-[12.5px]`}>
|
||||
<span className="font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3">{clockTime(timestampNs)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-ds-text">{trace?.name ?? fallbackTitle}</span>
|
||||
<span className="block font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||||
{traceId ? `trace:${traceId.slice(0, 12)}` : "no trace attached"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{trace ? (
|
||||
<>
|
||||
<span className="h-2 flex-1 overflow-hidden rounded-full bg-ds-fill">
|
||||
<span
|
||||
className={`block h-full rounded-full ${trace.errorCount > 0 ? "bg-ds-critical" : "bg-ds-accent"}`}
|
||||
style={{ width: `${Math.max(2, (trace.duration / maxDuration) * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||
{formatDuration(trace.duration)}
|
||||
</span>
|
||||
</>
|
||||
) : loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-ds-text-3" aria-label="Loading trace" />
|
||||
) : (
|
||||
<span className="text-ds-text-3">–</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">{trace?.spanCount ?? "–"}</span>
|
||||
<span className="truncate text-[11.5px] text-ds-text-3">{trace?.services.join(", ") || "–"}</span>
|
||||
<span className={`text-right font-[family-name:var(--font-data)] text-[11.5px] ${trace && trace.errorCount > 0 ? "text-ds-critical" : "text-ds-text-3"}`}>
|
||||
{trace ? trace.errorCount : "–"}
|
||||
</span>
|
||||
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||
{hits === undefined ? "" : `×${hits}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
Reference in new issue
Block a user