mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 08:30:33 +01:00
Compare commits
7 Commits
worktree-h
...
feat/span-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f25591024 | ||
|
|
d981ecc979 | ||
|
|
95801bd6df | ||
|
|
5c1f54ae36 | ||
|
|
3759d093c4 | ||
|
|
1e5b9f7701 | ||
|
|
3360058dd4 |
@@ -43,7 +43,7 @@
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -51,14 +51,14 @@
|
||||
|
||||
&__subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 1px dashed var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -120,15 +120,15 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// Stack the message and the resources card; card matches the content
|
||||
// width above it and is capped so it doesn't sprawl in a wide panel.
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
justify-content: center;
|
||||
margin-left: 21px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -142,7 +142,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
width: 332px;
|
||||
width: 100%; // match the content width above
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
@@ -155,7 +155,6 @@
|
||||
text-transform: uppercase;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
&__resources-links {
|
||||
|
||||
@@ -34,6 +34,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
.logsFooter {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
// Inset to match panelBody's 12px padding so the border-top aligns with
|
||||
// the content edges instead of bleeding to the panel border.
|
||||
margin: 0 12px;
|
||||
padding: 8px 0 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
TabsContent,
|
||||
@@ -49,6 +49,7 @@ import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanSummary from './SpanSummary';
|
||||
@@ -68,6 +69,9 @@ interface SpanDetailsPanelProps {
|
||||
// dock, or a floating/right panel widened to match). ~right-dock max width.
|
||||
const WIDE_PANEL_BREAKPOINT = 720;
|
||||
|
||||
// Context-log window padding around the span's trace time range.
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
@@ -77,12 +81,15 @@ function SpanDetailsContent({
|
||||
traceStartTime?: number;
|
||||
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);
|
||||
// Tracked so the panel can render tab-specific chrome (the Logs footer)
|
||||
// outside the scrolling tab content.
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
setActiveTab(tab);
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
@@ -281,89 +288,99 @@ function SpanDetailsContent({
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
return (
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sibling of the scrolling panelBody, so it's a true fixed footer flush
|
||||
with the panel's bottom edge — always visible, never scrolls. */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className={styles.logsFooter}>
|
||||
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
|
||||
interface OpenInLogsExplorerProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
|
||||
// Placement/alignment is the caller's responsibility.
|
||||
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClick}
|
||||
prefix={<Compass size={16} />}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenInLogsExplorer;
|
||||
@@ -1,5 +1,4 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
@@ -82,8 +84,10 @@ jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
// Mock OverlayScrollbar (default export — needs __esModule for the interop
|
||||
// unwrap, otherwise the default import resolves to the module object).
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
@@ -110,6 +114,13 @@ jest.mock(
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const sampleLog = {
|
||||
id: 'log-1',
|
||||
body: 'sample log body',
|
||||
timestamp: '1640995200000',
|
||||
spanID: TEST_SPAN_ID,
|
||||
} as unknown as ILog;
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
@@ -207,4 +218,22 @@ describe('SpanLogs', () => {
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens a new tab to Logs Explorer with the trace_id + span_id query when a log row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SpanLogs {...defaultProps} logs={[sampleLog]} />);
|
||||
|
||||
await user.click(screen.getByTestId(`raw-log-${sampleLog.id}`));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
// Opens Logs Explorer in a new tab, filtered to this trace/span, and
|
||||
// deep-links to the clicked log.
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(TEST_TRACE_ID);
|
||||
expect(url).toContain(TEST_SPAN_ID);
|
||||
expect(url).toContain(sampleLog.id); // activeLogId
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { SpanDetailVariant } from '../constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel';
|
||||
|
||||
// Mock window.open for the Open in Logs Explorer footer redirect.
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Placement is width-driven via useMeasure (jsdom reports 0), so we control the
|
||||
// reported width per test. `mock` prefix lets the jest.mock factory reference it.
|
||||
let mockWidth = 0;
|
||||
@@ -157,3 +165,42 @@ describe('SpanDetailsPanel – tabs', () => {
|
||||
expect(screen.getByTestId('logs-tab')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The footer lives on the panel body (not inside the tab content) so it stays
|
||||
// pinned to the panel's visible bottom edge; it should track the active tab.
|
||||
describe('SpanDetailsPanel – Open in Logs Explorer footer', () => {
|
||||
beforeEach(() => {
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
it('appears only while the Logs tab is active', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /overview/i }));
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens Logs Explorer in a new tab filtered to the span trace when clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
await user.click(screen.getByTestId('open-in-explorer-button'));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('trace-1');
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user