Compare commits

...

7 Commits

Author SHA1 Message Date
aks07
7f25591024 fix(trace-details): pin Open in Logs Explorer footer to the panel bottom
The footer lived inside the scrolling tab content, so it was pushed below
the fold whenever the span summary above the tabs was in view. Render it as
a sibling of the scrolling panelBody instead (Logs tab only), making it a
true fixed footer that is always visible.
2026-07-09 11:05:31 +05:30
Aditya Singh
d981ecc979 Merge branch 'main' into feat/span-logs-open-in-explorer 2026-07-09 00:04:58 +05:30
aks07
95801bd6df style(trace-details): fix oxfmt formatting in SpanLogs test 2026-07-09 00:03:37 +05:30
aks07
5c1f54ae36 test(trace-details): assert log-row click opens Logs Explorer with trace/span query 2026-07-08 23:06:04 +05:30
aks07
3759d093c4 test(trace-details): cover Open in Logs Explorer footer button in span details 2026-07-08 23:06:04 +05:30
aks07
1e5b9f7701 feat: add logs explorer btn in footer 2026-07-08 23:06:04 +05:30
aks07
3360058dd4 feat(trace-details): add Open in Logs Explorer to span details Logs tab
Restores the Open in Logs Explorer action (removed with V2 in #11805): a
toolbar button above the log list and an inline action in the no-trace-logs
empty state. Also tightens the empty-state RESOURCES card layout (stacked,
content-width, no extra height).
2026-07-08 23:06:04 +05:30
7 changed files with 227 additions and 95 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
)}
</>
);
}

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
.spanLogs {
margin-inline: var(--spacing-8);
height: 100%;
display: flex;
flex-direction: column;

View File

@@ -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');
});
});

View File

@@ -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');
});
});