materialized view and a bit better ui
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s

This commit is contained in:
mats committed 2026-09-19 12:43:17 +02:00
1 parent 26b677154b
commit 49f9bb3458
30 files changed
+2184 -106

No files matched your search

+149
View File
@@ -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
+22 -10
View File
@@ -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>
)}
+114
View File
@@ -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
+97 -88
View File
@@ -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>
)
+34
View File
@@ -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>
)