mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-10 06:00:19 +01:00
Compare commits
1 Commits
feat/trace
...
debug-wal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501ad64b9e |
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
|
||||
@@ -94,9 +93,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
},
|
||||
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
return signoz.NewAuditorProviderFactories()
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
|
||||
@@ -25,7 +24,6 @@ import (
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -135,13 +133,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
factories := signoz.NewAuditorProviderFactories()
|
||||
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
|
||||
@@ -364,34 +364,3 @@ serviceaccount:
|
||||
analytics:
|
||||
# toggle service account analytics
|
||||
enabled: true
|
||||
|
||||
##################### Auditor #####################
|
||||
auditor:
|
||||
# Specifies the auditor provider to use.
|
||||
# noop: discards all audit events (community default).
|
||||
# otlphttp: exports audit events via OTLP HTTP (enterprise).
|
||||
provider: noop
|
||||
# The async channel capacity for audit events. Events are dropped when full (fail-open).
|
||||
buffer_size: 1000
|
||||
# The maximum number of events per export batch.
|
||||
batch_size: 100
|
||||
# The maximum time between export flushes.
|
||||
flush_interval: 1s
|
||||
otlphttp:
|
||||
# The target scheme://host:port/path of the OTLP HTTP endpoint.
|
||||
endpoint: http://localhost:4318/v1/logs
|
||||
# Whether to use HTTP instead of HTTPS.
|
||||
insecure: false
|
||||
# The maximum duration for an export attempt.
|
||||
timeout: 10s
|
||||
# Additional HTTP headers sent with every export request.
|
||||
headers: {}
|
||||
retry:
|
||||
# Whether to retry on transient failures.
|
||||
enabled: true
|
||||
# The initial wait time before the first retry.
|
||||
initial_interval: 5s
|
||||
# The upper bound on backoff interval.
|
||||
max_interval: 30s
|
||||
# The total maximum time spent retrying.
|
||||
max_elapsed_time: 60s
|
||||
|
||||
@@ -227,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
// flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={timelineHeight * 2.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -33,125 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
dodgerBlue: '#2F80ED',
|
||||
royalBlue: '#3366E6',
|
||||
steelBlue: '#4682B4',
|
||||
|
||||
// Teals / Cyans
|
||||
turquoise: '#00CEC9',
|
||||
lagoon: '#1ABC9C',
|
||||
cyanBright: '#22A6F2',
|
||||
|
||||
// Greens
|
||||
emeraldGreen: '#27AE60',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
limeGreen: '#A3E635',
|
||||
|
||||
// Yellows / Golds
|
||||
festivalYellow: '#F2C94C',
|
||||
sunflower: '#FFD93D',
|
||||
warmAmber: '#FFCA28',
|
||||
|
||||
// Purples / Violets
|
||||
mediumPurple: '#BB6BD9',
|
||||
royalPurple: '#9B51E0',
|
||||
orchid: '#DA77F2',
|
||||
|
||||
// Accent
|
||||
neonViolet: '#C77DFF',
|
||||
electricPurple: '#6C5CE7',
|
||||
arcticBlue: '#48DBFB',
|
||||
|
||||
// Blues extended
|
||||
blue1: '#1F63E0',
|
||||
blue2: '#3A7AED',
|
||||
blue3: '#5A9DF5',
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
|
||||
// Cyans
|
||||
cyan1: '#00B0AA',
|
||||
cyan2: '#33D6C2',
|
||||
cyan3: '#66E9DA',
|
||||
|
||||
// Greens extended
|
||||
green1: '#1E8449',
|
||||
green2: '#2ECC71',
|
||||
green3: '#58D68D',
|
||||
green4: '#229954',
|
||||
green5: '#27AE60',
|
||||
green6: '#52BE80',
|
||||
|
||||
// Forest
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
|
||||
// Lime
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#D4FFB0',
|
||||
|
||||
// Teals
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#1ABC9C',
|
||||
teal5: '#48C9B0',
|
||||
teal6: '#76D7C4',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
|
||||
// Gold
|
||||
gold1: '#F39C12',
|
||||
gold2: '#F1C40F',
|
||||
gold3: '#F7DC6F',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
|
||||
// Mustard
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
|
||||
// Aqua
|
||||
aqua1: '#00BFFF',
|
||||
aqua2: '#1E90FF',
|
||||
aqua3: '#63B8FF',
|
||||
|
||||
// Purple extended
|
||||
purple1: '#8E44AD',
|
||||
purple2: '#9B59B6',
|
||||
purple3: '#BB8FCE',
|
||||
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
violet4: '#7D3C98',
|
||||
violet5: '#8E44AD',
|
||||
violet6: '#9B59B6',
|
||||
|
||||
// Lavender
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
|
||||
// Oranges (safe ones, not red-ish)
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.infra-metrics-card {
|
||||
margin: 1rem 0;
|
||||
height: 300px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -163,16 +163,16 @@ function NodeMetrics({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={widgetInfo[idx].title}>
|
||||
<Col span={12} key={widgetInfo[idx].title}>
|
||||
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -146,16 +146,16 @@ function PodMetrics({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={podWidgetInfo[idx].title}>
|
||||
<Col span={12} key={podWidgetInfo[idx].title}>
|
||||
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -677,18 +677,6 @@ function NewWidget({
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
...(currentQuery.queryType === EQueryType.CLICKHOUSE && {
|
||||
clickhouseQueryCount: currentQuery.clickhouse_sql.length,
|
||||
clickhouseQueries: currentQuery.clickhouse_sql.map((q) => ({
|
||||
name: q.name,
|
||||
query: (q.query ?? '')
|
||||
.replace(/--[^\n]*/g, '') // strip line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
||||
.replace(/'(?:[^'\\]|\\.|'')*'/g, "'?'") // replace single-quoted strings (handles \' and '' escapes)
|
||||
.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, '?'), // replace numeric literals (int, float, scientific)
|
||||
disabled: q.disabled,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -7,23 +7,6 @@ export function hashFn(str: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function colorToRgb(color: string): string {
|
||||
// Handle hex colors
|
||||
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
if (hexMatch) {
|
||||
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
|
||||
hexMatch[2],
|
||||
16,
|
||||
)}, ${parseInt(hexMatch[3], 16)}`;
|
||||
}
|
||||
// Handle rgb() colors
|
||||
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
|
||||
if (rgbMatch) {
|
||||
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
|
||||
}
|
||||
return '136, 136, 136';
|
||||
}
|
||||
|
||||
export function generateColor(
|
||||
key: string,
|
||||
colorMap: Record<string, string>,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
.event-tooltip-content {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
max-width: 300px;
|
||||
|
||||
&__header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: rgb(14, 165, 233);
|
||||
|
||||
&.error {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
&__attributes {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__key {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__value {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { Diamond } from 'lucide-react';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './EventTooltipContent.styles.scss';
|
||||
|
||||
export interface EventTooltipContentProps {
|
||||
eventName: string;
|
||||
timeOffsetMs: number;
|
||||
isError: boolean;
|
||||
attributeMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function EventTooltipContent({
|
||||
eventName,
|
||||
timeOffsetMs,
|
||||
isError,
|
||||
attributeMap,
|
||||
}: EventTooltipContentProps): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
|
||||
|
||||
return (
|
||||
<div className="event-tooltip-content">
|
||||
<div className="event-tooltip-content__header">
|
||||
<Diamond size={10} />
|
||||
<span>EVENT DETAILS</span>
|
||||
</div>
|
||||
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="event-tooltip-content__time">
|
||||
{toFixed(time, 2)} {timeUnitName} from start
|
||||
</div>
|
||||
{Object.keys(attributeMap).length > 0 && (
|
||||
<>
|
||||
<div className="event-tooltip-content__divider" />
|
||||
<div className="event-tooltip-content__attributes">
|
||||
{Object.entries(attributeMap).map(([key, value]) => (
|
||||
<div key={key} className="event-tooltip-content__kv">
|
||||
<span className="event-tooltip-content__key">{key}:</span>{' '}
|
||||
<span className="event-tooltip-content__value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
.span-hover-card-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: rgba(30, 30, 30, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.span-hover-card-content {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SpanTooltipContentProps {
|
||||
spanName: string;
|
||||
color: string;
|
||||
hasError: boolean;
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
spanName,
|
||||
color,
|
||||
hasError,
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
|
||||
durationMs,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="span-hover-card-content">
|
||||
<div className="span-hover-card-content__name" style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Status: {hasError ? 'error' : 'ok'}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Start: {toFixed(relativeStartMs, 2)} ms
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const durationMs = span.durationNano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
if (span.hasError) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
mouseEnterDelay={0.2}
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.hasError}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,297 +0,0 @@
|
||||
// Modal base styles
|
||||
.add-span-to-funnel-modal {
|
||||
&__loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
}
|
||||
&-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
background: var(--bg-ink-400);
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px !important;
|
||||
.add-span-to-funnel-modal {
|
||||
&__save-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
width: 135px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--bg-vanilla-400);
|
||||
.ant-btn-icon {
|
||||
svg {
|
||||
stroke: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__discard-button {
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal styles
|
||||
.add-span-to-funnel-modal {
|
||||
// Common button styles
|
||||
%button-base {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
// Details view styles
|
||||
&--details {
|
||||
.traces-funnel-details {
|
||||
height: unset;
|
||||
|
||||
&__steps-config {
|
||||
width: unset;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.funnel-step-wrapper {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search section
|
||||
&__search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-input-prefix {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create button
|
||||
&__create-button {
|
||||
@extend %button-base;
|
||||
width: 153px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
.funnel-item {
|
||||
padding: 8px 8px 12px 16px;
|
||||
&,
|
||||
&:first-child {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
line-height: 20px;
|
||||
}
|
||||
&__details {
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// List section
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
.funnels-empty {
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.funnels-list {
|
||||
gap: 8px;
|
||||
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
|
||||
&__details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
// Back button
|
||||
&__back-button {
|
||||
@extend %button-base;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
// Details section
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.funnel-configuration__steps {
|
||||
padding: 0;
|
||||
|
||||
.funnel-step {
|
||||
&__content .filters__service-and-span .ant-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&__footer .error {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.inter-step-config {
|
||||
width: calc(100% - 104px);
|
||||
}
|
||||
}
|
||||
.funnel-item__actions-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.add-span-to-funnel-modal-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&-footer {
|
||||
border-top-color: var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
.add-span-to-funnel-modal__discard-button {
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-span-to-funnel-modal {
|
||||
&__search-input {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
input {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__create-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__back-button {
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__details h3 {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ArrowLeft, Check, Plus, Search } from 'lucide-react';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import {
|
||||
FunnelProvider,
|
||||
useFunnelContext,
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
|
||||
enum ModalView {
|
||||
LIST = 'list',
|
||||
DETAILS = 'details',
|
||||
}
|
||||
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
onChangesDetected,
|
||||
triggerDiscard,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
triggerAutoSave: boolean;
|
||||
showNotifications: boolean;
|
||||
onChangesDetected: (hasChanges: boolean) => void;
|
||||
triggerDiscard: boolean;
|
||||
}): JSX.Element {
|
||||
const { handleRestoreSteps, steps } = useFunnelContext();
|
||||
|
||||
// Track changes between current steps and original steps
|
||||
useEffect(() => {
|
||||
const hasChanges = !isEqual(steps, funnel.steps);
|
||||
if (onChangesDetected) {
|
||||
onChangesDetected(hasChanges);
|
||||
}
|
||||
}, [steps, funnel.steps, onChangesDetected]);
|
||||
|
||||
// Handle discard when triggered from parent
|
||||
useEffect(() => {
|
||||
if (triggerDiscard && funnel.steps) {
|
||||
handleRestoreSteps(funnel.steps);
|
||||
}
|
||||
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
|
||||
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
funnel={funnel}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
isSpanDetailsPage
|
||||
/>
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
function AddSpanToFunnelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
span,
|
||||
}: AddSpanToFunnelModalProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
const [triggerSave, setTriggerSave] = useState<boolean>(false);
|
||||
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
|
||||
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
|
||||
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useFunnelsList();
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
),
|
||||
[data?.payload, searchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: funnelDetails,
|
||||
isLoading: isFunnelDetailsLoading,
|
||||
isFetching: isFunnelDetailsFetching,
|
||||
} = useFunnelDetails({
|
||||
funnelId: selectedFunnelId,
|
||||
});
|
||||
|
||||
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||
setSelectedFunnelId(funnel.funnel_id);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
setIsUnsavedChanges(false);
|
||||
setTriggerSave(false);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveFunnel = (): void => {
|
||||
setTriggerSave(true);
|
||||
// Reset trigger after a brief moment to allow the save to be processed
|
||||
setTimeout(() => {
|
||||
setTriggerSave(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setTriggerDiscard(true);
|
||||
// Reset trigger after a brief moment
|
||||
setTimeout(() => {
|
||||
setTriggerDiscard(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
<div className="add-span-to-funnel-modal__search">
|
||||
<Input
|
||||
className="add-span-to-funnel-modal__search-input"
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} />}
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="add-span-to-funnel-modal__list">
|
||||
<OverlayScrollbar>
|
||||
<TracesFunnelsContentRenderer
|
||||
isError={isError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={filteredData || []}
|
||||
onCreateFunnel={handleCreateNewClick}
|
||||
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<CreateFunnel
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(funnelId): void => {
|
||||
if (funnelId) {
|
||||
setSelectedFunnelId(funnelId);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(true);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
redirectToDetails={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
|
||||
<Button
|
||||
type="text"
|
||||
className="add-span-to-funnel-modal__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All funnels
|
||||
</Button>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<Spin
|
||||
className="add-span-to-funnel-modal__loading-spinner"
|
||||
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
|
||||
indicator={<LoadingOutlined spin />}
|
||||
>
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider
|
||||
funnelId={selectedFunnelId}
|
||||
hasSingleStep={isCreatedFromSpan}
|
||||
>
|
||||
<FunnelDetailsView
|
||||
funnel={funnelDetails.payload}
|
||||
span={span}
|
||||
triggerAutoSave={triggerSave}
|
||||
showNotifications
|
||||
onChangesDetected={setIsUnsavedChanges}
|
||||
triggerDiscard={triggerDiscard}
|
||||
/>
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={570}
|
||||
title="Add span to funnel"
|
||||
className={cx('add-span-to-funnel-modal-container', {
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
footer={
|
||||
activeView === ModalView.DETAILS
|
||||
? [
|
||||
<Button
|
||||
type="default"
|
||||
key="discard"
|
||||
onClick={handleDiscard}
|
||||
className="add-span-to-funnel-modal__discard-button"
|
||||
disabled={!isUnsavedChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
className="add-span-to-funnel-modal__save-button"
|
||||
onClick={handleSaveFunnel}
|
||||
disabled={!isUnsavedChanges}
|
||||
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
|
||||
>
|
||||
Save Funnel
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="create"
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
? renderListView()
|
||||
: renderDetailsView({ span })}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSpanToFunnelModal;
|
||||
@@ -1,39 +0,0 @@
|
||||
.span-line-action-buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
|
||||
.ant-btn-default {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 9px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.span-line-action-buttons {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-400);
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders copy link button with correct icon', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the button is rendered
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
|
||||
// Check if the link icon is rendered
|
||||
const linkIcon = screen.getByRole('img', { hidden: true });
|
||||
expect(linkIcon).toHaveClass('anticon anticon-link');
|
||||
});
|
||||
|
||||
it('calls onSpanCopy when copy button is clicked', () => {
|
||||
const mockOnSpanCopy = jest.fn();
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: mockOnSpanCopy,
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called
|
||||
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the main container has the correct class
|
||||
const container = screen
|
||||
.getByRole('button')
|
||||
.closest('.span-line-action-buttons');
|
||||
expect(container).toHaveClass('span-line-action-buttons');
|
||||
|
||||
// Check if the button has the correct class
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toHaveClass('copy-span-btn');
|
||||
});
|
||||
|
||||
it('copies span link to clipboard when copy button is clicked', () => {
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
delete: jest.fn(),
|
||||
set: jest.fn(),
|
||||
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
|
||||
};
|
||||
const mockPathname = '/test-path';
|
||||
const mockLocation = {
|
||||
origin: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock useCopySpanLink hook
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
mockSetCopy(link);
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called with correct link
|
||||
expect(mockSetCopy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/test-path?spanId=test-span-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './SpanLineActionButtons.styles.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
}: SpanLineActionButtonsProps): JSX.Element {
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<Tooltip title="Copy Span Link">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined size={14} />}
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.trace-waterfall {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
}
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
uncollapsedNodes: string[];
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
errorFetchingTraceData: unknown;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
}
|
||||
|
||||
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
|
||||
const {
|
||||
traceData,
|
||||
isFetchingTraceData,
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
traceId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
// get the current state of trace waterfall based on the API lifecycle
|
||||
const traceWaterfallState = useMemo(() => {
|
||||
if (isFetchingTraceData) {
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length > 0
|
||||
) {
|
||||
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceWaterfallStates.LOADING;
|
||||
}
|
||||
if (errorFetchingTraceData) {
|
||||
return TraceWaterfallStates.ERROR;
|
||||
}
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length === 0
|
||||
) {
|
||||
return TraceWaterfallStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceWaterfallStates.SUCCESS;
|
||||
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(() => traceData?.payload?.spans || [], [
|
||||
traceData?.payload?.spans,
|
||||
]);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceWaterfallState) {
|
||||
case TraceWaterfallStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceWaterfallStates.ERROR:
|
||||
return <Error error={errorFetchingTraceData as AxiosError} />;
|
||||
case TraceWaterfallStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceWaterfallStates.SUCCESS:
|
||||
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
traceMetadata={{
|
||||
traceId,
|
||||
startTime: traceData?.payload?.startTimestampMillis || 0,
|
||||
endTime: traceData?.payload?.endTimestampMillis || 0,
|
||||
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
|
||||
}}
|
||||
interestedSpanId={interestedSpanId || ''}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
isFetching={
|
||||
traceWaterfallState ===
|
||||
TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT
|
||||
}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
selectedSpan,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
spans,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceData?.payload?.hasMissingSpans,
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceId,
|
||||
traceWaterfallState,
|
||||
uncollapsedNodes,
|
||||
]);
|
||||
|
||||
return <div className="trace-waterfall">{getContent}</div>;
|
||||
}
|
||||
|
||||
export default TraceWaterfall;
|
||||
@@ -1,30 +0,0 @@
|
||||
.error-waterfall {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
margin: 20px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-cherry-500);
|
||||
|
||||
.text {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-waterfall">
|
||||
<Typography.Text className="text">Something went wrong!</Typography.Text>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="value" ellipsis>
|
||||
{error?.message}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
interface INoDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { id } = props;
|
||||
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -1,60 +0,0 @@
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 0px 20px;
|
||||
gap: 12px;
|
||||
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pre-next-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.filter-row {
|
||||
.pre-next-toggle {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Spin, Tooltip, Typography } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
|
||||
import './Filters.styles.scss';
|
||||
|
||||
function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
return {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
filters: {
|
||||
...filters,
|
||||
items: [
|
||||
...filters.items,
|
||||
{
|
||||
id: '5ab8e1cf',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'trace_id--string----true',
|
||||
},
|
||||
op: '=',
|
||||
value: traceID,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Filters({
|
||||
startTime,
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
searchParams.set('spanId', filteredSpanIds[index]);
|
||||
}
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history, search],
|
||||
);
|
||||
|
||||
const { isFetching, error } = useGetQueryRange(
|
||||
{
|
||||
query: prepareQuery(filters, traceID),
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
{
|
||||
queryKey: [filters],
|
||||
enabled: filters.items.length > 0,
|
||||
onSuccess: (data) => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
if (data?.payload.data.newResult.data.result[0].list) {
|
||||
const uniqueSpans = uniqBy(
|
||||
data?.payload.data.newResult.data.result[0].list,
|
||||
'data.spanID',
|
||||
);
|
||||
|
||||
const spanIds = uniqueSpans.map((val) => val.data.spanID);
|
||||
setFilteredSpanIds(spanIds);
|
||||
onFilteredSpansChange?.(spanIds, isFilterActive);
|
||||
handlePrevNext(0, spanIds[0]);
|
||||
setNoData(false);
|
||||
} else {
|
||||
setNoData(true);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
skipQueryBuilderRedirect
|
||||
selectProps={{ listHeight: 125 }}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
disabled={currentSearchedIndex === 0}
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
type="text"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
{error && (
|
||||
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
|
||||
<InfoCircleOutlined size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{noData && (
|
||||
<Typography.Text className="no-results">No results found</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Filters.defaultProps = {
|
||||
onFilteredSpansChange: undefined,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const BASE_FILTER_QUERY: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.EMPTY,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: 200,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
selectColumns: [],
|
||||
};
|
||||
@@ -1,553 +0,0 @@
|
||||
.waterfall-loading-bar {
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
animation: waterfall-loading 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes waterfall-loading {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: scaleX(0.3);
|
||||
transform-origin: left;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scaleX(0.3);
|
||||
transform-origin: right;
|
||||
}
|
||||
}
|
||||
|
||||
.success-content {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.missing-spans {
|
||||
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);
|
||||
|
||||
.left-info {
|
||||
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; /* 142.857% */
|
||||
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; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.right-info:hover {
|
||||
background-color: unset;
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-split-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0px 20px 0px 20px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-split-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
height: 25px;
|
||||
background-color: var(--bg-ink-500);
|
||||
|
||||
.sidebar-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resize-handle-header {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-split-body {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.waterfall-sidebar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
|
||||
.resizable-box__content {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-tree-table {
|
||||
position: relative;
|
||||
border-collapse: collapse;
|
||||
|
||||
.span-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.span-tree-cell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.span-tree-row:hover,
|
||||
.span-tree-row.hovered-span {
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
|
||||
.span-overview {
|
||||
background: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-resize-handle {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: rgba(35, 196, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-timeline {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-row:hover,
|
||||
.timeline-row.hovered-span {
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared span component styles (used in both panels)
|
||||
.span-overview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
.tree-indent {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-line {
|
||||
position: absolute;
|
||||
background-color: var(--bg-slate-400);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-connector {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 50%;
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
border-bottom-left-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&.no-children {
|
||||
cursor: default;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 0 6px;
|
||||
|
||||
&.is-error {
|
||||
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
color: #fff;
|
||||
font-family: 'Inter';
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 28px;
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.span-row-actions {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
padding-right: 4px;
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, var(--bg-ink-500) 60%, transparent);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.span-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .span-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Also show action buttons when hovering the tree row (parent of span-overview)
|
||||
.span-tree-row:hover .span-row-actions,
|
||||
.span-tree-row.hovered-span .span-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.span-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.span-bar {
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
top: 5px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
background-color: var(--span-color);
|
||||
border: 1px solid transparent;
|
||||
|
||||
.span-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
.span-name {
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.span-duration-text {
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: var(--event-dot-bg, var(--bg-robin-500));
|
||||
border: 1px solid var(--event-dot-border, var(--bg-robin-600));
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&.error {
|
||||
background-color: var(--bg-cherry-500);
|
||||
border-color: var(--bg-cherry-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hover state: static stripe pattern + border
|
||||
.timeline-row:hover .span-bar,
|
||||
.timeline-row.hovered-span .span-bar {
|
||||
color: var(--span-color);
|
||||
background-color: rgba(var(--span-color-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--span-color-rgb), 0.2);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(var(--span-color-rgb), 0.04) 10px,
|
||||
rgba(var(--span-color-rgb), 0.04) 20px
|
||||
);
|
||||
}
|
||||
|
||||
// Selected state: stripe pattern + dashed border
|
||||
.interested-span .span-bar,
|
||||
.selected-non-matching-span .span-bar {
|
||||
color: var(--span-color);
|
||||
background-color: rgba(var(--span-color-rgb), 0.1);
|
||||
border: 1px dashed var(--span-color);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(var(--span-color-rgb), 0.04) 10px,
|
||||
rgba(var(--span-color-rgb), 0.04) 20px
|
||||
);
|
||||
}
|
||||
|
||||
// Shared state classes for both panels
|
||||
.interested-span,
|
||||
.selected-non-matching-span {
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.selected-non-matching-span {
|
||||
.tree-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-dets {
|
||||
.related-logs {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Slate-500, #161922);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.success-content {
|
||||
.span-overview {
|
||||
.tree-arrow {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.tree-line {
|
||||
background-color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.tree-connector {
|
||||
border-color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.span-row-actions {
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--bg-vanilla-100) 60%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.span-action-btn {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.interested-span {
|
||||
border-radius: 4px;
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.span-duration .span-bar {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
// Light mode hover/selected: span color must override the white default above
|
||||
.timeline-row:hover .span-bar,
|
||||
.timeline-row.hovered-span .span-bar {
|
||||
color: var(--span-color);
|
||||
}
|
||||
|
||||
.interested-span .span-bar,
|
||||
.selected-non-matching-span .span-bar {
|
||||
color: var(--span-color);
|
||||
}
|
||||
|
||||
.waterfall-sidebar {
|
||||
border-right: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.waterfall-split-header {
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
}
|
||||
.span-dets {
|
||||
.related-logs {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
} from 'lucide-react';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../TraceWaterfall';
|
||||
import Filters from './Filters/Filters';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
// css config
|
||||
const CONNECTOR_WIDTH = 20;
|
||||
const VERTICAL_CONNECTOR_WIDTH = 1;
|
||||
|
||||
interface ITraceMetadata {
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
interface ISuccessProps {
|
||||
spans: Span[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
uncollapsedNodes: string[];
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
const SpanOverview = memo(function SpanOverview({
|
||||
span,
|
||||
isSpanCollapsed,
|
||||
handleCollapseUncollapse,
|
||||
handleSpanClick,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
onAddSpanToFunnel,
|
||||
}: {
|
||||
span: Span;
|
||||
isSpanCollapsed: boolean;
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
onAddSpanToFunnel: (span: Span) => void;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
if (span.hasError) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
}
|
||||
|
||||
// Smart highlighting logic
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
|
||||
|
||||
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
onAddSpanToFunnel(span);
|
||||
};
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
|
||||
{!isRootSpan &&
|
||||
Array.from({ length: span.level }, (_, i) => {
|
||||
const lvl = i + 1;
|
||||
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
|
||||
if (lvl < span.level) {
|
||||
// Stop the line at 50% for the last child's parent level
|
||||
const isLastChildParentLine = !span.hasSibling && lvl === span.level - 1;
|
||||
return (
|
||||
<div
|
||||
key={lvl}
|
||||
className="tree-line"
|
||||
style={{
|
||||
left: xPos,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: isLastChildParentLine ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={lvl}>
|
||||
<div
|
||||
className="tree-line"
|
||||
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
|
||||
/>
|
||||
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Indent spacer */}
|
||||
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
|
||||
|
||||
{/* Expand/collapse arrow or leaf bullet */}
|
||||
{span.hasChildren ? (
|
||||
<span
|
||||
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
|
||||
}}
|
||||
>
|
||||
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tree-arrow no-children" />
|
||||
)}
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx('tree-icon', { 'is-error': span.hasError })}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Span name */}
|
||||
<span className="tree-label">{span.name}</span>
|
||||
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<Tooltip title="Copy Span Link">
|
||||
<button type="button" className="span-action-btn" onClick={onSpanCopy}>
|
||||
<Link size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Add to Trace Funnel">
|
||||
<button
|
||||
type="button"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
export const SpanDuration = memo(function SpanDuration({
|
||||
span,
|
||||
traceMetadata,
|
||||
handleSpanClick,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
span.durationNano / 1e6,
|
||||
);
|
||||
|
||||
const spread = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
|
||||
const width = (span.durationNano * 1e2) / (spread * 1e6);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
let rgbColor = colorToRgb(color);
|
||||
|
||||
if (span.hasError) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
rgbColor = '239, 68, 68';
|
||||
}
|
||||
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('span-duration', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className="span-bar"
|
||||
style={
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color-rgb': rgbColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="span-info">
|
||||
<span className="span-name">{span.name}</span>
|
||||
<span className="span-duration-text">{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
{span.event?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const spanDurationMs = span.durationNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const { isError } = event;
|
||||
// Position relative to the span bar: leftOffset% + clampedOffset% of width%
|
||||
const dotLeft = leftOffset + (clampedOffset / 100) * width;
|
||||
const parts = rgbColor.split(', ');
|
||||
const dotBg = `rgb(${parts
|
||||
.map((c) => Math.round(Number(c) * 0.7))
|
||||
.join(', ')})`;
|
||||
const dotBorder = `rgb(${parts
|
||||
.map((c) => Math.round(Number(c) * 0.5))
|
||||
.join(', ')})`;
|
||||
return (
|
||||
<Popover
|
||||
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
|
||||
content={
|
||||
<EventTooltipContent
|
||||
eventName={event.name}
|
||||
timeOffsetMs={eventTimeMs - span.timestamp}
|
||||
isError={isError}
|
||||
attributeMap={event.attributeMap || {}}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
<div
|
||||
className={`event-dot ${isError ? 'error' : ''}`}
|
||||
style={
|
||||
{
|
||||
left: `${dotLeft}%`,
|
||||
'--event-dot-bg': isError ? undefined : dotBg,
|
||||
'--event-dot-border': isError ? undefined : dotBorder,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// table config
|
||||
const columnDefHelper = createColumnHelper<Span>();
|
||||
|
||||
const ROW_HEIGHT = 28;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 450;
|
||||
const MIN_SIDEBAR_WIDTH = 240;
|
||||
const MAX_SIDEBAR_WIDTH = 900;
|
||||
const BASE_CONTENT_WIDTH = 300;
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
traceMetadata,
|
||||
interestedSpanId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
isFetching,
|
||||
} = props;
|
||||
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
|
||||
const prevHoveredSpanIdRef = useRef<string | null>(null);
|
||||
|
||||
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
|
||||
const applyHoverClass = useCallback((spanId: string | null): void => {
|
||||
const prev = prevHoveredSpanIdRef.current;
|
||||
if (prev === spanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prev) {
|
||||
const prevElements = document.querySelectorAll(`[data-span-id="${prev}"]`);
|
||||
prevElements.forEach((el) => el.classList.remove('hovered-span'));
|
||||
}
|
||||
if (spanId) {
|
||||
const nextElements = document.querySelectorAll(`[data-span-id="${spanId}"]`);
|
||||
nextElements.forEach((el) => el.classList.add('hovered-span'));
|
||||
}
|
||||
prevHoveredSpanIdRef.current = spanId;
|
||||
}, []);
|
||||
|
||||
const handleRowMouseEnter = useCallback(
|
||||
(spanId: string): void => {
|
||||
applyHoverClass(spanId);
|
||||
},
|
||||
[applyHoverClass],
|
||||
);
|
||||
|
||||
const handleRowMouseLeave = useCallback((): void => {
|
||||
applyHoverClass(null);
|
||||
}, [applyHoverClass]);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean) => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
setIsFilterActive(isActive);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
(spanId: string, collapse: boolean) => {
|
||||
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
|
||||
},
|
||||
[setInterestedSpanId],
|
||||
);
|
||||
|
||||
const handleVirtualizerInstanceChanged = useCallback(
|
||||
(instance: Virtualizer<HTMLDivElement, Element>): void => {
|
||||
const { range } = instance;
|
||||
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
|
||||
// do not trigger the API call
|
||||
if (spans.length < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.startIndex === 0 && instance.isScrolling) {
|
||||
// do not trigger for trace root as nothing to fetch above
|
||||
if (spans[0].level !== 0) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[0].spanId,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[spans.length - 1].spanId,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[spans, setInterestedSpanId],
|
||||
);
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
|
||||
Span | undefined
|
||||
>(undefined);
|
||||
const handleAddSpanToFunnel = useCallback((span: Span): void => {
|
||||
setIsAddSpanToFunnelModalOpen(true);
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: Span): void => {
|
||||
setSelectedSpan(span);
|
||||
if (span?.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
},
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
// Left side columns using TanStack React Table (extensible for future columns)
|
||||
const leftColumns = useMemo(
|
||||
() => [
|
||||
columnDefHelper.display({
|
||||
id: 'span-name',
|
||||
header: '',
|
||||
cell: (cellProps): JSX.Element => (
|
||||
<SpanOverview
|
||||
span={cellProps.row.original}
|
||||
handleCollapseUncollapse={handleCollapseUncollapse}
|
||||
isSpanCollapsed={
|
||||
!uncollapsedNodes.includes(cellProps.row.original.spanId)
|
||||
}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
handleAddSpanToFunnel,
|
||||
],
|
||||
);
|
||||
|
||||
const leftTable = useReactTable({
|
||||
data: spans,
|
||||
columns: leftColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// Shared virtualizer - one instance drives both panels
|
||||
const virtualizer = useVirtualizer({
|
||||
count: spans.length,
|
||||
getScrollElement: (): HTMLDivElement | null => scrollContainerRef.current,
|
||||
estimateSize: (): number => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
onChange: handleVirtualizerInstanceChanged,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
virtualizerRef.current = virtualizer;
|
||||
}, [virtualizer]);
|
||||
|
||||
// Compute max content width for sidebar horizontal scroll
|
||||
const maxContentWidth = useMemo(() => {
|
||||
if (spans.length === 0) {
|
||||
return sidebarWidth;
|
||||
}
|
||||
const maxLevel = spans.reduce((max, span) => Math.max(max, span.level), 0);
|
||||
return Math.max(
|
||||
sidebarWidth,
|
||||
maxLevel * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH) + BASE_CONTENT_WIDTH,
|
||||
);
|
||||
}, [spans, sidebarWidth]);
|
||||
|
||||
// Scroll to interested span
|
||||
useEffect(() => {
|
||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||
const idx = spans.findIndex(
|
||||
(span) => span.spanId === interestedSpanId.spanId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
setTimeout(() => {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, 400);
|
||||
|
||||
setSelectedSpan(spans[idx]);
|
||||
}
|
||||
} else {
|
||||
setSelectedSpan((prev) => {
|
||||
if (!prev) {
|
||||
return spans[0];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const leftRows = leftTable.getRowModel().rows;
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
<div className="missing-spans">
|
||||
<section className="left-info">
|
||||
<AlertCircle size={14} />
|
||||
<Typography.Text className="text">
|
||||
This trace has missing spans
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<Button
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
className="right-info"
|
||||
type="text"
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
startTime={traceMetadata.startTime / 1e3}
|
||||
endTime={traceMetadata.endTime / 1e3}
|
||||
traceID={traceMetadata.traceId}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
/>
|
||||
{isFetching && <div className="waterfall-loading-bar" />}
|
||||
<div className="waterfall-split-panel" ref={scrollContainerRef}>
|
||||
{/* Sticky header row */}
|
||||
<div className="waterfall-split-header">
|
||||
<div
|
||||
className="sidebar-header"
|
||||
style={{ width: sidebarWidth, flexShrink: 0 }}
|
||||
/>
|
||||
<div className="resize-handle-header" />
|
||||
<div className="timeline-header">
|
||||
<TimelineV3
|
||||
startTimestamp={traceMetadata.startTime}
|
||||
endTimestamp={traceMetadata.endTime}
|
||||
timelineHeight={10}
|
||||
offsetTimestamp={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split body */}
|
||||
<div
|
||||
className="waterfall-split-body"
|
||||
style={{
|
||||
minHeight: virtualizer.getTotalSize(),
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Left panel - table with horizontal scroll */}
|
||||
<ResizableBox
|
||||
direction="horizontal"
|
||||
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
|
||||
minWidth={MIN_SIDEBAR_WIDTH}
|
||||
maxWidth={MAX_SIDEBAR_WIDTH}
|
||||
onResize={setSidebarWidth}
|
||||
className="waterfall-sidebar"
|
||||
>
|
||||
<table className="span-tree-table" style={{ width: maxContentWidth }}>
|
||||
<tbody>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const row = leftRows[virtualRow.index];
|
||||
const span = spans[virtualRow.index];
|
||||
return (
|
||||
<tr
|
||||
key={String(virtualRow.key)}
|
||||
data-testid={`cell-0-${span.spanId}`}
|
||||
data-span-id={span.spanId}
|
||||
className="span-tree-row"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="span-tree-cell">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ResizableBox>
|
||||
|
||||
{/* Right panel - timeline bars */}
|
||||
<div className="waterfall-timeline">
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const span = spans[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={String(virtualRow.key)}
|
||||
data-testid={`cell-1-${span.spanId}`}
|
||||
data-span-id={span.spanId}
|
||||
className="timeline-row"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
>
|
||||
<SpanDuration
|
||||
span={span}
|
||||
traceMetadata={traceMetadata}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
@@ -1,268 +0,0 @@
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
|
||||
const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
jest.mock('components/TimelineV3/TimelineV3', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1160000, // 1ms in nano
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1234567000,
|
||||
endTime: 1234569000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SpanDuration', () => {
|
||||
const mockSetSelectedSpan = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryGet = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock URL query hook
|
||||
(useUrlQuery as jest.Mock).mockReturnValue({
|
||||
set: mockUrlQuerySet,
|
||||
get: mockUrlQueryGet,
|
||||
toString: () => 'spanId=test-span-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls handleSpanClick when clicked', () => {
|
||||
const mockHandleSpanClick = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockHandleSpanClick}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the span duration element
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
fireEvent.click(spanElement);
|
||||
|
||||
// Verify handleSpanClick was called with the correct span
|
||||
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
|
||||
});
|
||||
|
||||
it('shows action buttons on hover', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
|
||||
// Initially, action buttons should not be visible
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
// Hover over the span
|
||||
fireEvent.mouseEnter(spanElement);
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
|
||||
// Mouse leave should hide the buttons
|
||||
fireEvent.mouseLeave(spanElement);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['other-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['different-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]} // Empty array but filter is active
|
||||
isFilterActive // This is the key difference
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
@@ -1,419 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import Success from '../Success';
|
||||
|
||||
// Mock the required hooks with proper typing
|
||||
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
|
||||
(params: { search: string }) => void
|
||||
>;
|
||||
const mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
|
||||
|
||||
// App provider is already handled by test-utils
|
||||
|
||||
// React Router is already globally mocked
|
||||
|
||||
// Mock complex external dependencies that cause provider issues
|
||||
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
|
||||
function SpanHoverCard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
SpanHoverCard.displayName = 'SpanHoverCard';
|
||||
return SpanHoverCard;
|
||||
});
|
||||
|
||||
// Mock the Filters component that's causing React Query issues
|
||||
jest.mock('../Filters/Filters', () => {
|
||||
function Filters(): null {
|
||||
return null;
|
||||
}
|
||||
Filters.displayName = 'Filters';
|
||||
return Filters;
|
||||
});
|
||||
|
||||
// Mock other potential dependencies
|
||||
jest.mock(
|
||||
'pages/TraceDetailsV3/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
|
||||
() => {
|
||||
function AddSpanToFunnelModal(): null {
|
||||
return null;
|
||||
}
|
||||
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
|
||||
return AddSpanToFunnelModal;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('pages/TraceDetailsV3/TraceWaterfall/SpanLineActionButtons', () => {
|
||||
function SpanLineActionButtons(): null {
|
||||
return null;
|
||||
}
|
||||
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
|
||||
return SpanLineActionButtons;
|
||||
});
|
||||
|
||||
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
|
||||
function HttpStatusBadge(): null {
|
||||
return null;
|
||||
}
|
||||
HttpStatusBadge.displayName = 'HttpStatusBadge';
|
||||
return HttpStatusBadge;
|
||||
});
|
||||
|
||||
jest.mock('components/TimelineV3/TimelineV3', () => {
|
||||
function TimelineV3(): null {
|
||||
return null;
|
||||
}
|
||||
TimelineV3.displayName = 'TimelineV3';
|
||||
return { __esModule: true, default: TimelineV3 };
|
||||
});
|
||||
|
||||
// Mock other utilities that might cause issues
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (): string => '#1890ff',
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
value: number,
|
||||
): { time: number; timeUnitName: string } => ({
|
||||
time: value < 1000 ? value : value / 1000,
|
||||
timeUnitName: value < 1000 ? 'ms' : 's',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('utils/toFixed', () => ({
|
||||
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
|
||||
}));
|
||||
|
||||
// Mock useVirtualizer to render all items without actual virtualization
|
||||
jest.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({
|
||||
count,
|
||||
}: {
|
||||
count: number;
|
||||
}): {
|
||||
getVirtualItems: () => Array<{
|
||||
index: number;
|
||||
key: number;
|
||||
start: number;
|
||||
size: number;
|
||||
}>;
|
||||
getTotalSize: () => number;
|
||||
scrollToIndex: jest.Mock;
|
||||
} => ({
|
||||
getVirtualItems: (): Array<{
|
||||
index: number;
|
||||
key: number;
|
||||
start: number;
|
||||
size: number;
|
||||
}> =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
index: i,
|
||||
key: i,
|
||||
start: i * 54,
|
||||
size: 54,
|
||||
})),
|
||||
getTotalSize: (): number => count * 54,
|
||||
scrollToIndex: jest.fn(),
|
||||
}),
|
||||
Virtualizer: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const createMockSpan = (spanId: string, level = 1): Span => ({
|
||||
spanId,
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'span-1',
|
||||
parentSpanId: level === 0 ? '' : 'span-1',
|
||||
name: `Test Span ${spanId}`,
|
||||
serviceName: 'test-service',
|
||||
timestamp: mockTraceMetadata.startTime + level * 100000,
|
||||
durationNano: 50000000,
|
||||
level,
|
||||
hasError: false,
|
||||
kind: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'Test Root Span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
});
|
||||
|
||||
const mockSpans = [
|
||||
createMockSpan('span-1', 0),
|
||||
createMockSpan('span-2', 1),
|
||||
createMockSpan('span-3', 1),
|
||||
];
|
||||
|
||||
// Shared TestComponent for all tests
|
||||
function TestComponent(): JSX.Element {
|
||||
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Span Click User Flows', () => {
|
||||
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
|
||||
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
|
||||
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
|
||||
const SPAN_OVERVIEW_CLASS = '.span-overview';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Initially URL should not have spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBeNull();
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify URL was updated with spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click on span-2 to test selection change
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2DurationElement);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('both click areas produce the same visual result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Initially both areas should show the same visual selection (first span is auto-selected)
|
||||
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click span-2 to test selection change
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Now span-2 should be selected, span-1 should not
|
||||
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
|
||||
// Check that span-2 is selected
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2OverviewElement = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking different spans updates selection correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click second span
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Second span should be selected, first should not
|
||||
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing URL parameters when selecting spans', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Pre-populate URL with existing parameters
|
||||
mockUrlQuery.set('existingParam', 'existingValue');
|
||||
mockUrlQuery.set('anotherParam', 'anotherValue');
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
// Verify navigation was called with all parameters
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringMatching(
|
||||
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum TraceWaterfallStates {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
.resizable-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--disabled {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--vertical {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
|
||||
export interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
onResize?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ResizableBox({
|
||||
children,
|
||||
direction = 'vertical',
|
||||
defaultHeight = 200,
|
||||
minHeight = 50,
|
||||
maxHeight = Infinity,
|
||||
defaultWidth = 200,
|
||||
minWidth = 50,
|
||||
maxWidth = Infinity,
|
||||
onResize,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||||
const startSize = size;
|
||||
const min = isHorizontal ? minWidth : minHeight;
|
||||
const max = isHorizontal ? maxWidth : maxHeight;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = currentPos - startPos;
|
||||
const newSize = Math.min(max, Math.max(min, startSize + delta));
|
||||
setSize(newSize);
|
||||
onResize?.(newSize);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
|
||||
);
|
||||
|
||||
const containerStyle = disabled
|
||||
? undefined
|
||||
: isHorizontal
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`resizable-box ${disabled ? 'resizable-box--disabled' : ''} ${
|
||||
className || ''
|
||||
}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="resizable-box__content">{children}</div>
|
||||
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResizableBox;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ResizableBoxProps } from './ResizableBox';
|
||||
export { default as ResizableBox } from './ResizableBox';
|
||||
@@ -63,7 +63,6 @@ type RetryConfig struct {
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
Provider: "noop",
|
||||
BufferSize: 1000,
|
||||
BatchSize: 100,
|
||||
FlushInterval: time.Second,
|
||||
|
||||
@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
@@ -124,9 +123,6 @@ type Config struct {
|
||||
|
||||
// ServiceAccount config
|
||||
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
|
||||
|
||||
// Auditor config
|
||||
Auditor auditor.Config `mapstructure:"auditor"`
|
||||
}
|
||||
|
||||
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
|
||||
@@ -157,7 +153,6 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
|
||||
user.NewConfigFactory(),
|
||||
identn.NewConfigFactory(),
|
||||
serviceaccount.NewConfigFactory(),
|
||||
auditor.NewConfigFactory(),
|
||||
}
|
||||
|
||||
conf, err := config.New(ctx, resolverConfig, configFactories)
|
||||
|
||||
@@ -3,8 +3,6 @@ package signoz
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
@@ -314,12 +312,6 @@ func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[fac
|
||||
)
|
||||
}
|
||||
|
||||
func NewAuditorProviderFactories() factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
noopauditor.NewFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
func NewFlaggerProviderFactories(registry featuretypes.Registry) factory.NamedMap[factory.ProviderFactory[flagger.FlaggerProvider, flagger.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
configflagger.NewFactory(registry),
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
@@ -76,7 +75,6 @@ type SigNoz struct {
|
||||
QueryParser queryparser.QueryParser
|
||||
Flagger flagger.Flagger
|
||||
Gateway gateway.Gateway
|
||||
Auditor auditor.Auditor
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -96,7 +94,6 @@ func New(
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
|
||||
) (*SigNoz, error) {
|
||||
// Initialize instrumentation
|
||||
@@ -374,12 +371,6 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize auditor from the variant-specific provider factories
|
||||
auditor, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Auditor, auditorProviderFactories(licensing), config.Auditor.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize authns
|
||||
store := sqlauthnstore.NewStore(sqlstore)
|
||||
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
|
||||
@@ -479,7 +470,6 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
|
||||
factory.NewNamedService(factory.MustNewName("authz"), authz),
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -526,6 +516,5 @@ func New(
|
||||
QueryParser: queryParser,
|
||||
Flagger: flagger,
|
||||
Gateway: gateway,
|
||||
Auditor: auditor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -23,6 +25,7 @@ type provider struct {
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
formatter sqlstore.SQLFormatter
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
||||
@@ -59,13 +62,19 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
|
||||
sqliteDialect := sqlitedialect.New()
|
||||
bunDB := sqlstore.NewBunDB(settings, sqldb, sqliteDialect, hooks)
|
||||
return &provider{
|
||||
|
||||
done := make(chan struct{})
|
||||
p := &provider{
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: bunDB,
|
||||
dialect: new(dialect),
|
||||
formatter: newFormatter(bunDB.Dialect()),
|
||||
}, nil
|
||||
done: done,
|
||||
}
|
||||
go p.walDiagnosticLoop(config.Sqlite.Path)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (provider *provider) BunDB() *bun.DB {
|
||||
@@ -109,3 +118,73 @@ func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, for
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// walDiagnosticLoop periodically logs pool stats, WAL file size, and busy prepared statements
|
||||
// to help diagnose WAL checkpoint failures caused by permanent read locks.
|
||||
func (provider *provider) walDiagnosticLoop(dbPath string) {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
logger := provider.settings.Logger()
|
||||
walPath := dbPath + "-wal"
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 1. Log pool stats (no SQL needed)
|
||||
stats := provider.sqldb.Stats()
|
||||
logger.Info("sqlite_pool_stats",
|
||||
slog.Int("max_open", stats.MaxOpenConnections),
|
||||
slog.Int("open", stats.OpenConnections),
|
||||
slog.Int("in_use", stats.InUse),
|
||||
slog.Int("idle", stats.Idle),
|
||||
slog.Int64("wait_count", stats.WaitCount),
|
||||
slog.String("wait_duration", stats.WaitDuration.String()),
|
||||
slog.Int64("max_idle_closed", stats.MaxIdleClosed),
|
||||
slog.Int64("max_idle_time_closed", stats.MaxIdleTimeClosed),
|
||||
slog.Int64("max_lifetime_closed", stats.MaxLifetimeClosed),
|
||||
)
|
||||
|
||||
// 2. Log WAL file size (no SQL needed)
|
||||
if info, err := os.Stat(walPath); err == nil {
|
||||
logger.Info("sqlite_wal_size",
|
||||
slog.Int64("bytes", info.Size()),
|
||||
slog.String("path", walPath),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Check for busy prepared statements on a single pool connection
|
||||
provider.checkBusyStatements(logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) checkBusyStatements(logger *slog.Logger) {
|
||||
conn, err := provider.sqldb.Conn(context.Background())
|
||||
if err != nil {
|
||||
logger.Warn("sqlite_diag_conn_error", slog.String("error", err.Error()))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rows, err := conn.QueryContext(context.Background(), "SELECT sql FROM sqlite_stmt WHERE busy")
|
||||
if err != nil {
|
||||
logger.Warn("sqlite_diag_query_error", slog.String("error", err.Error()))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var stmtSQL string
|
||||
if err := rows.Scan(&stmtSQL); err != nil {
|
||||
logger.Warn("sqlite_diag_scan_error", slog.String("error", err.Error()))
|
||||
continue
|
||||
}
|
||||
logger.Warn("leaked_busy_statement", slog.String("sql", stmtSQL))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
logger.Warn("sqlite_diag_rows_error", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user