mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 04:40:37 +01:00
Compare commits
5 Commits
feat/water
...
issue-5388
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd02f14bc9 | ||
|
|
87c56fb636 | ||
|
|
ef2ba2a46a | ||
|
|
b91e664e14 | ||
|
|
04ec681eda |
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -680,13 +680,6 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Datetime', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('formats datetime units', () => {
|
||||
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
|
||||
'56 years ago',
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.members-settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.members-settings {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie } from '@visx/shape';
|
||||
@@ -8,10 +8,12 @@ import { themeColors } from 'constants/theme';
|
||||
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||
import { preparePieChartData } from './preparePieChartData';
|
||||
import { lightenColor, tooltipStyles } from './utils';
|
||||
|
||||
import './PiePanelWrapper.styles.scss';
|
||||
@@ -42,15 +44,37 @@ function PiePanelWrapper({
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const panelData = queryResponse.data?.payload?.data?.result || [];
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const pieChartData = useMemo(
|
||||
() =>
|
||||
preparePieChartData(queryResponse.data?.payload, {
|
||||
customLegendColors: widget?.customLegendColors,
|
||||
colorMap: isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
}),
|
||||
[queryResponse.data?.payload, widget?.customLegendColors, isDarkMode],
|
||||
let pieChartData: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}[] = [].concat(
|
||||
...(panelData
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d?.values?.[0]?.[1],
|
||||
record: d,
|
||||
color:
|
||||
widget?.customLegendColors?.[label] ||
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((d) => d !== undefined) as never[]),
|
||||
);
|
||||
|
||||
pieChartData = pieChartData.filter(
|
||||
(arc) =>
|
||||
arc.value && !isNaN(parseFloat(arc.value)) && parseFloat(arc.value) > 0,
|
||||
);
|
||||
|
||||
let size = 0;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { preparePieChartData } from '../preparePieChartData';
|
||||
|
||||
const options = { colorMap: themeColors.chartcolors };
|
||||
|
||||
/**
|
||||
* Mirrors a query-range payload: the (possibly collapsed) time-series `result`
|
||||
* plus the scalar table nested under `newResult` (as getQueryResults produces it).
|
||||
*/
|
||||
function makePayload(
|
||||
result: QueryData[],
|
||||
tables: QueryDataV3[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result,
|
||||
resultType: 'scalar',
|
||||
newResult: { data: { result: tables, resultType: 'scalar' } },
|
||||
},
|
||||
} as MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
function tableEntry(
|
||||
columns: NonNullable<QueryDataV3['table']>['columns'],
|
||||
rows: NonNullable<QueryDataV3['table']>['rows'],
|
||||
overrides: Partial<QueryDataV3> = {},
|
||||
): QueryDataV3 {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: { columns, rows },
|
||||
...overrides,
|
||||
} as QueryDataV3;
|
||||
}
|
||||
|
||||
describe('preparePieChartData', () => {
|
||||
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
|
||||
// SELECT count() AS col1, sum(value) AS col2 — the backend collapses the
|
||||
// time-series result onto col1; the full data lives in the scalar table.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
values: [[0, '23399927']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { col1: 23399927, col2: 588691297 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(2);
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['col1', '23399927'],
|
||||
['col2', '588691297'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes the group when multiple value columns are grouped', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual([
|
||||
'prod · col1',
|
||||
'prod · col2',
|
||||
]);
|
||||
expect(slices[0].record.metric).toStrictEqual({ env: 'prod' });
|
||||
});
|
||||
|
||||
it('drops non-positive and non-numeric values', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
|
||||
],
|
||||
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
|
||||
});
|
||||
|
||||
it('keeps the series path for a single value column (grouped panel)', () => {
|
||||
// One value column → the time-series result is authoritative (one slice per
|
||||
// group), so existing behaviour is preserved.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: 'adservice',
|
||||
values: [[0, '100']],
|
||||
} as QueryData,
|
||||
{
|
||||
metric: { 'service.name': 'cartservice' },
|
||||
queryName: 'A',
|
||||
legend: 'cartservice',
|
||||
values: [[0, '200']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
[
|
||||
{ data: { 'service.name': 'adservice', A: 100 } },
|
||||
{ data: { 'service.name': 'cartservice', A: 200 } },
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['adservice', '100'],
|
||||
['cartservice', '200'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the legacy series result when there is no scalar table', () => {
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: '{{service.name}}',
|
||||
values: [[1000, '42']],
|
||||
} as QueryData,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0].value).toBe('42');
|
||||
});
|
||||
|
||||
it('returns no slices for an empty payload', () => {
|
||||
expect(preparePieChartData(undefined, options)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export interface PieChartSlice {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: {
|
||||
queryName: string;
|
||||
legend?: string;
|
||||
/** Group-by labels, used for drilldown; absent when the slice has no group. */
|
||||
metric?: QueryData['metric'];
|
||||
};
|
||||
}
|
||||
|
||||
interface PreparePieChartDataOptions {
|
||||
customLegendColors?: Record<string, string>;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
const colorFor = (
|
||||
label: string,
|
||||
{ customLegendColors, colorMap }: PreparePieChartDataOptions,
|
||||
): string => customLegendColors?.[label] || generateColor(label, colorMap);
|
||||
|
||||
const isPositive = (value: string): boolean =>
|
||||
!!value && !isNaN(parseFloat(value)) && parseFloat(value) > 0;
|
||||
|
||||
/**
|
||||
* Time-series result: one slice per series, value = first datapoint. This is the
|
||||
* original pie behaviour — kept verbatim (same label/value/colour/record) so
|
||||
* single-value and grouped panels are unaffected.
|
||||
*/
|
||||
function slicesFromSeries(
|
||||
result: QueryData[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
return result
|
||||
.filter((d) => d?.values?.[0]?.[1] !== undefined)
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d.values[0][1],
|
||||
color: colorFor(label, options),
|
||||
record: d,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 scalar table: one slice per (row × value column). With more than one value
|
||||
* column the column name keeps the slices distinct, so a ClickHouse query like
|
||||
* `count() AS col1, sum() AS col2` renders a slice per column instead of
|
||||
* collapsing onto the first; group-by columns become the slice label.
|
||||
*/
|
||||
function slicesFromTables(
|
||||
tables: QueryDataV3[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const slices: PieChartSlice[] = [];
|
||||
|
||||
tables.forEach((entry) => {
|
||||
const { table } = entry;
|
||||
if (!table?.columns?.length || !table?.rows?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueColumns = table.columns.filter((column) => column.isValueColumn);
|
||||
if (valueColumns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
const hasMultipleValueColumns = valueColumns.length > 1;
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const groupLabel = labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ');
|
||||
// Drilldown filters by group-by labels; leave it undefined when there
|
||||
// are none (e.g. a ClickHouse query) so no filterless menu is offered.
|
||||
const metric = labelColumns.length
|
||||
? labelColumns.reduce<Record<string, string>>((acc, column) => {
|
||||
acc[column.name] = String(row.data[column.id || column.name]);
|
||||
return acc;
|
||||
}, {})
|
||||
: undefined;
|
||||
|
||||
valueColumns.forEach((column) => {
|
||||
let label: string;
|
||||
if (hasMultipleValueColumns) {
|
||||
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
|
||||
} else {
|
||||
label = groupLabel || entry.legend || entry.queryName || '';
|
||||
}
|
||||
|
||||
slices.push({
|
||||
label,
|
||||
value: String(row.data[column.id || column.name]),
|
||||
color: colorFor(label, options),
|
||||
record: { queryName: entry.queryName, legend: entry.legend, metric },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return slices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pie slices from a query-range payload, dropping non-positive/non-numeric
|
||||
* values.
|
||||
*
|
||||
* A scalar response with several value columns (e.g. a ClickHouse
|
||||
* `count() AS col1, sum() AS col2`) collapses to a single series in
|
||||
* `data.result` — only the first value column survives. The full data is kept in
|
||||
* the scalar table under `newResult`, so in that case slices are built from the
|
||||
* table (one per value column). Otherwise the legacy time-series result is used,
|
||||
* preserving existing behaviour for single-value and grouped panels.
|
||||
*/
|
||||
export function preparePieChartData(
|
||||
payload: MetricRangePayloadProps | undefined,
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const tables = (payload?.data?.newResult?.data?.result || []).filter(
|
||||
(entry) => entry?.table?.rows?.length,
|
||||
);
|
||||
const hasMultipleValueColumns = tables.some(
|
||||
(entry) =>
|
||||
(entry.table?.columns || []).filter((column) => column.isValueColumn)
|
||||
.length > 1,
|
||||
);
|
||||
|
||||
const slices = hasMultipleValueColumns
|
||||
? slicesFromTables(tables, options)
|
||||
: slicesFromSeries(payload?.data?.result || [], options);
|
||||
|
||||
return slices.filter((slice) => isPositive(slice.value));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
|
||||
.rolesSettingsContent {
|
||||
padding: 0 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsToolbar {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
.container {
|
||||
// Gutter matches the header/subHeader 16px; bottom gap before the panels.
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './MissingSpansBanner.module.scss';
|
||||
|
||||
const MISSING_SPANS_DOCS_URL =
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans';
|
||||
|
||||
function MissingSpansBanner(): JSX.Element | null {
|
||||
// Session-only dismissal — not persisted, so the banner returns on reload.
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wrapper owns the gutter: Callout is width:100%, so putting the gutter as a
|
||||
// margin on it would overflow the parent by the margin width. Pad instead.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={(): void => setIsDismissed(true)}
|
||||
testId="missing-spans-banner"
|
||||
title={
|
||||
<span className={styles.title}>
|
||||
This trace has missing spans
|
||||
<a
|
||||
className={styles.link}
|
||||
href={MISSING_SPANS_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn More <ArrowUpRight size={14} />
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MissingSpansBanner;
|
||||
@@ -31,7 +31,6 @@ import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
@@ -49,7 +48,6 @@ export interface TraceMetadataForHeader {
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
@@ -231,8 +229,6 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
title="Preview fields"
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
:global(.ant-collapse-header) {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
@@ -99,13 +98,6 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// The flamegraph's ResizableBox above renders a 1px resize handle at its
|
||||
// bottom edge; drop the header's own top border so the two don't stack
|
||||
// into a double border at the flamegraph/waterfall juncture.
|
||||
:global(.ant-collapse-header) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -49,6 +49,59 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.missingSpans {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(69, 104, 220, 0.1);
|
||||
}
|
||||
|
||||
.leftInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.rightInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
}
|
||||
|
||||
.splitPanel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -163,12 +216,10 @@
|
||||
|
||||
.treeRow:hover,
|
||||
.treeRow.hoveredSpan {
|
||||
// Left end of the row band — round only the outer (left) corners so the
|
||||
// highlight joins the status + timeline segments into one continuous band.
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
var(--l3-background) 20%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
@@ -211,22 +262,20 @@
|
||||
--badge-border-width: 0px;
|
||||
|
||||
&.hoveredSpan {
|
||||
// Middle segment of the row band — square so it butts up against the
|
||||
// name and timeline segments (no rounded corner at the badge column).
|
||||
border-radius: 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
var(--l3-background) 20%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
border-radius: 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 80%,
|
||||
var(--l3-background) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -260,21 +309,20 @@
|
||||
|
||||
&:hover,
|
||||
&.hoveredSpan {
|
||||
// Right end of the row band — round only the outer (right) corners.
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
var(--l3-background) 20%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&:has(.isInterested),
|
||||
&:has(.isSelectedNonMatching) {
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 80%,
|
||||
var(--l3-background) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -297,11 +345,10 @@
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
// Left end of the row band — outer (left) corners only.
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 80%,
|
||||
var(--l3-background) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -424,7 +471,7 @@
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
|
||||
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -552,18 +599,6 @@
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
// A dimmed span must still show the full-opacity hover state when hovered.
|
||||
// These win over `.isDimmed` on specificity so brightness is restored across
|
||||
// the whole row (name column, status cell, and timeline bar) on hover.
|
||||
.treeRow:hover .isDimmed,
|
||||
.treeRow.hoveredSpan .isDimmed,
|
||||
.timelineRow:hover .isDimmed,
|
||||
.timelineRow.hoveredSpan .isDimmed,
|
||||
.statusCell:hover.isDimmed,
|
||||
.statusCell.hoveredSpan.isDimmed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.isHighlighted {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Link,
|
||||
ListPlus,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -847,6 +854,28 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
<div className={styles.missingSpans}>
|
||||
<section className={styles.leftInfo}>
|
||||
<CircleAlert size={14} />
|
||||
<span className={styles.text}>This trace has missing spans</span>
|
||||
</section>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.rightInfo}
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && <div className={styles.loadingBar} />}
|
||||
<div className={styles.splitPanel} ref={scrollContainerRef}>
|
||||
{/* Sticky header row */}
|
||||
@@ -965,8 +994,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
data-span-id={span.span_id}
|
||||
onMouseEnter={(): void => applyHoverClass(span.span_id)}
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
@@ -39,16 +34,6 @@ import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -344,7 +329,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
@@ -404,7 +388,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
@@ -459,7 +442,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
|
||||
@@ -338,6 +338,7 @@ func isValidLabelValue(v string) bool {
|
||||
// validate runs during UnmarshalJSON (read + write path).
|
||||
// Preserves the original pre-existing checks only so that stored rules
|
||||
// continue to load without errors.
|
||||
// TODO(srikanthccv): remove this once v1 is deprecated and removed.
|
||||
func (r *PostableRule) validate() error {
|
||||
var errs []error
|
||||
|
||||
@@ -366,9 +367,13 @@ func (r *PostableRule) validate() error {
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
messages[i] = e.Error()
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule definition is not valid").
|
||||
WithAdditional(messages...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -466,9 +471,13 @@ func (r *PostableRule) Validate() error {
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
messages[i] = e.Error()
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule is not valid").
|
||||
WithAdditional(messages...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,23 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func errorContains(err error, substr string) bool {
|
||||
j := errors.AsJSON(err)
|
||||
if strings.Contains(j.Message, substr) {
|
||||
return true
|
||||
}
|
||||
for _, e := range j.Errors {
|
||||
if strings.Contains(e.Message, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validV1Builder returns a minimal valid v1 builder rule JSON.
|
||||
func validV1Builder() string {
|
||||
return `{
|
||||
@@ -494,7 +509,7 @@ func TestValidate_PostableRule_Common(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else {
|
||||
@@ -687,7 +702,7 @@ func TestValidate_V1_ConditionFields(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if validateErr == nil {
|
||||
t.Errorf("expected Validate() error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(validateErr.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(validateErr, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, validateErr)
|
||||
}
|
||||
} else {
|
||||
@@ -1029,7 +1044,7 @@ func TestValidate_V2Alpha1(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else {
|
||||
@@ -1337,7 +1352,7 @@ func TestValidate_MultipleErrors(t *testing.T) {
|
||||
t.Fatal("expected unmarshal error for wrong version")
|
||||
}
|
||||
// The error should mention version
|
||||
if !strings.Contains(err.Error(), "version") {
|
||||
if !errorContains(err, "version") {
|
||||
t.Errorf("expected error to mention version, got: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -1355,10 +1370,9 @@ func TestValidate_MultipleErrors(t *testing.T) {
|
||||
if validateErr == nil {
|
||||
t.Fatal("expected Validate() error")
|
||||
}
|
||||
errStr := validateErr.Error()
|
||||
// Should contain errors for thresholds, evaluation, notificationSettings
|
||||
for _, substr := range []string{"evaluation", "notificationSettings"} {
|
||||
if !strings.Contains(errStr, substr) {
|
||||
if !errorContains(validateErr, substr) {
|
||||
t.Errorf("expected error to mention %q, got: %v", substr, validateErr)
|
||||
}
|
||||
}
|
||||
@@ -1469,7 +1483,7 @@ func TestValidate_V2Alpha1_CumulativeEvaluation(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user