Compare commits

...

4 Commits

Author SHA1 Message Date
aks07
3385776cce feat(trace-details): rework span details panel
Extract the summary into a SpanSummary component, place it by panel width
(above the tabs when narrow, inside Overview when wide), single-scroll sticky
tabs, and fix service/status-message badge overflow + truncation.
2026-06-30 20:20:17 +05:30
aks07
f5286d69f6 fix(trace-details): single borders for docked span details 2026-06-30 20:20:15 +05:30
aks07
bcbac9a15c refactor(trace-details): give TraceIdField its own CSS module 2026-06-30 20:20:09 +05:30
aks07
43038c59b5 feat(trace-details): full-width span percentile panel with visible borders 2026-06-30 20:20:07 +05:30
9 changed files with 247 additions and 228 deletions

View File

@@ -1,4 +1,4 @@
.root {
.panel {
height: 100%;
display: flex;
flex-direction: column;
@@ -9,13 +9,14 @@
}
}
.body {
.panelBody {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
@@ -33,14 +34,17 @@
min-width: 0;
}
// Single scroll: when the summary sits above (non-docked modes) it scrolls away
// and the tab section pins to the top; tab content scrolls inside `.tabsScroll`.
.tabsSection {
flex: 1;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
position: sticky;
top: 0;
flex: 0 0 auto;
height: 100%;
// TabsRoot — direct child of tabs-section
> div {
@@ -75,79 +79,6 @@
}
}
.spanRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
.spanInfo {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
.spanInfoItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
.highlightedOptions {
padding: 8px 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
// KeyValueLabel uses a global `.key-value-label` root; constrain it
// inside the two-column grid so values can ellipsize cleanly.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.serviceDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
}
.statusMessageBadge {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.traceId {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.traceIdCopy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
// tooltip (z-index 50). Bump the tooltip above the panel.

View File

@@ -5,20 +5,11 @@ import {
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
Bookmark,
CalendarClock,
ChartColumnBig,
Link2,
List,
ScrollText,
Timer,
} from '@signozhq/icons';
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
import { Skeleton } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -41,14 +32,13 @@ import {
} from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
import { useMeasure } from 'react-use';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
SpanDetailVariant,
@@ -57,17 +47,10 @@ import {
import DockModeSwitcher from './DockModeSwitcher';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import Events from './Events/Events';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -80,6 +63,10 @@ interface SpanDetailsPanelProps {
traceEndTime?: number;
}
// At/above this panel width the summary moves inside the Overview tab (bottom
// dock, or a floating/right panel widened to match). ~right-dock max width.
const WIDE_PANEL_BREAKPOINT = 720;
function SpanDetailsContent({
selectedSpan,
traceStartTime,
@@ -90,6 +77,7 @@ function SpanDetailsContent({
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
const spanAttributeActions = useSpanAttributeActions();
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
const handleTabChange = useCallback(
@@ -101,8 +89,6 @@ function SpanDetailsContent({
},
[logTraceEvent, selectedSpan.span_id],
);
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// One-time conversion of any V2-format value still living in the
// `span_details_pinned_attributes` user pref into V3 nested-path format.
@@ -281,113 +267,20 @@ function SpanDetailsContent({
// .map((key) => ({ key, value: allAttrs[key] }));
// }, [selectedSpan]);
// Width-driven: when the panel is wide, the summary moves inside the Overview
// tab; when narrow it stays above the tabs.
const isWide = bodyWidth >= WIDE_PANEL_BREAKPOINT;
const summary = (
<SpanSummary
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
return (
<div className={styles.body}>
<div className={styles.detailsSection}>
<div className={styles.spanRow}>
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className={styles.spanInfo}>
<div className={styles.spanInfoItem}>
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.duration_nano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className={styles.spanInfoItem}>
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className={styles.spanInfoItem}>
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
{/* Step 6: HighlightedOptions */}
<div className={styles.highlightedOptions}>
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
{keyAttributes.length > 0 && (
<div className="span-details-panel__key-attributes">
<div className="span-details-panel__key-attributes-label">
KEY ATTRIBUTES
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<ActionMenu
key={key}
items={buildKeyAttrMenu(key, value)}
trigger={['click']}
placement="bottomRight"
>
<div>
<KeyValueLabel badgeKey={key} badgeValue={value} />
</div>
</ActionMenu>
))}
</div>
</div>
)}
*/}
{/* Step 8: MiniTraceContext */}
</div>
<div className={styles.panelBody} ref={bodyRef}>
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
<div className={styles.tabsSection}>
{/* Step 9: ContentTabs */}
@@ -411,6 +304,7 @@ function SpanDetailsContent({
<div className={styles.tabsScroll}>
<TabsContent value="overview">
{isWide && summary}
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"
@@ -535,7 +429,7 @@ function SpanDetailsPanel({
traceEndTime={traceEndTime}
/>
) : (
<div className={styles.body}>
<div className={styles.panelBody}>
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
</div>
)}
@@ -546,7 +440,7 @@ function SpanDetailsPanel({
variant === SpanDetailVariant.DOCKED ||
variant === SpanDetailVariant.DOCKED_RIGHT
) {
return <div className={styles.root}>{content}</div>;
return <div className={styles.panel}>{content}</div>;
}
if (variant === SpanDetailVariant.DRAWER) {
@@ -554,7 +448,7 @@ function SpanDetailsPanel({
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className={styles.root}
className={styles.panel}
>
{content}
</DetailsPanelDrawer>
@@ -564,7 +458,7 @@ function SpanDetailsPanel({
return (
<FloatingPanel
isOpen={panelState.isOpen}
className={styles.root}
className={styles.panel}
width={PANEL_WIDTH}
minWidth={480}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}

View File

@@ -3,11 +3,12 @@
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
margin: 8px 0 0;
width: 100%;
}
.header {
@@ -16,7 +17,7 @@
justify-content: space-between;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l2-border);
}
.content {
@@ -162,20 +163,20 @@
.resourceSelector {
overflow: hidden;
width: calc(100% + 16px);
width: 100%;
position: absolute;
top: 32px;
left: -8px;
left: 0;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.resourceSelectorHeader {
border-bottom: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l2-border);
}
.resourceSelectorInput {

View File

@@ -0,0 +1,61 @@
.spanRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
.spanInfo {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
.spanInfoItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
.highlightedOptions {
padding: 8px 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
// Constrain KeyValueLabel inside the grid so values can ellipsize cleanly.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.serviceDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
}
// Badges stay fit-content so short values shrink and long ones truncate.
.serviceBadge,
.statusMessageBadge {
max-width: 100%;
min-width: 0;
}
// Truncating text inside a badge (service name, status message).
.badgeEllipsisText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,116 @@
import { CalendarClock, Link2, Timer } from '@signozhq/icons';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import styles from './SpanSummary.module.scss';
interface SpanSummaryProps {
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
}
// Summary block shown above (narrow) / beside (wide) the tabs: span name +
// percentile, exec time / timestamp / linked spans, and the highlighted options.
function SpanSummary({
selectedSpan,
traceStartTime,
traceEndTime,
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
return (
<>
<div className={styles.spanRow}>
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
<div className={styles.spanInfo}>
<div className={styles.spanInfoItem}>
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.duration_nano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className={styles.spanInfoItem}>
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className={styles.spanInfoItem}>
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
<div className={styles.highlightedOptions}>
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
</>
);
}
export default SpanSummary;

View File

@@ -0,0 +1,18 @@
.traceId {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.traceIdCopy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}

View File

@@ -5,7 +5,7 @@ import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanDetailsPanel.module.scss';
import styles from './TraceIdField.module.scss';
interface TraceIdFieldProps {
span: SpanV3;

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import ExpandableValue from 'periscope/components/ExpandableValue';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanDetailsPanel.module.scss';
import styles from './SpanSummary.module.scss';
import { TraceIdField } from './TraceIdField';
interface HighlightedOption {
@@ -18,9 +18,11 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
label: 'SERVICE',
render: (span): ReactNode | null =>
span['service.name'] ? (
<Badge color="vanilla">
<Badge color="vanilla" className={styles.serviceBadge}>
<span className={styles.serviceDot} />
{span['service.name']}
<span className={styles.badgeEllipsisText} title={span['service.name']}>
{span['service.name']}
</span>
</Badge>
) : null,
},
@@ -50,12 +52,8 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
render: (span): ReactNode | null =>
span.status_message ? (
<ExpandableValue value={span.status_message} title="Status message">
<Badge
color="vanilla"
textEllipsis="end"
className={styles.statusMessageBadge}
>
{span.status_message}
<Badge color="vanilla" className={styles.statusMessageBadge}>
<span className={styles.badgeEllipsisText}>{span.status_message}</span>
</Badge>
</ExpandableValue>
) : null,

View File

@@ -22,8 +22,8 @@
.rightDock {
display: flex;
flex-direction: column;
border-left: 1px solid var(--l2-border);
min-width: 0;
border-top: 1px solid var(--l2-border);
}
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall