Compare commits
10 Commits
feat/cmd-c
...
feat/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa73b9d19 | ||
|
|
fb671872a9 | ||
|
|
d05d394f57 | ||
|
|
b4e5085a5a | ||
|
|
88f7502a15 | ||
|
|
b0442761ac | ||
|
|
6814c236b2 | ||
|
|
f1371f965e | ||
|
|
9fd4d3eeb8 | ||
|
|
e27fd996c3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,7 +106,6 @@ downloads/
|
|||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
lib/
|
||||||
!frontend/src/lib/
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ services:
|
|||||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||||
signoz:
|
signoz:
|
||||||
!!merge <<: *db-depend
|
!!merge <<: *db-depend
|
||||||
image: signoz/signoz:v0.97.0
|
image: signoz/signoz:v0.98.0
|
||||||
command:
|
command:
|
||||||
- --config=/root/config/prometheus.yml
|
- --config=/root/config/prometheus.yml
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ services:
|
|||||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||||
signoz:
|
signoz:
|
||||||
!!merge <<: *db-depend
|
!!merge <<: *db-depend
|
||||||
image: signoz/signoz:v0.97.0
|
image: signoz/signoz:v0.98.0
|
||||||
command:
|
command:
|
||||||
- --config=/root/config/prometheus.yml
|
- --config=/root/config/prometheus.yml
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ services:
|
|||||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||||
signoz:
|
signoz:
|
||||||
!!merge <<: *db-depend
|
!!merge <<: *db-depend
|
||||||
image: signoz/signoz:${VERSION:-v0.97.0}
|
image: signoz/signoz:${VERSION:-v0.98.0}
|
||||||
container_name: signoz
|
container_name: signoz
|
||||||
command:
|
command:
|
||||||
- --config=/root/config/prometheus.yml
|
- --config=/root/config/prometheus.yml
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ services:
|
|||||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||||
signoz:
|
signoz:
|
||||||
!!merge <<: *db-depend
|
!!merge <<: *db-depend
|
||||||
image: signoz/signoz:${VERSION:-v0.97.0}
|
image: signoz/signoz:${VERSION:-v0.98.0}
|
||||||
container_name: signoz
|
container_name: signoz
|
||||||
command:
|
command:
|
||||||
- --config=/root/config/prometheus.yml
|
- --config=/root/config/prometheus.yml
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package postgressqlschema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||||
@@ -47,50 +48,45 @@ func (provider *provider) Operator() sqlschema.SQLOperator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) {
|
func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) {
|
||||||
rows, err := provider.
|
columns := []struct {
|
||||||
|
ColumnName string `bun:"column_name"`
|
||||||
|
Nullable bool `bun:"nullable"`
|
||||||
|
SQLDataType string `bun:"udt_name"`
|
||||||
|
DefaultVal *string `bun:"column_default"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err := provider.
|
||||||
sqlstore.
|
sqlstore.
|
||||||
BunDB().
|
BunDB().
|
||||||
QueryContext(ctx, `
|
NewRaw(`
|
||||||
SELECT
|
SELECT
|
||||||
c.column_name,
|
c.column_name,
|
||||||
c.is_nullable = 'YES',
|
c.is_nullable = 'YES' as nullable,
|
||||||
c.udt_name,
|
c.udt_name,
|
||||||
c.column_default
|
c.column_default
|
||||||
FROM
|
FROM
|
||||||
information_schema.columns AS c
|
information_schema.columns AS c
|
||||||
WHERE
|
WHERE
|
||||||
c.table_name = ?`, string(tableName))
|
c.table_name = ?`, string(tableName)).
|
||||||
|
Scan(ctx, &columns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return nil, nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
sqlschemaColumns := make([]*sqlschema.Column, 0)
|
||||||
if err := rows.Close(); err != nil {
|
for _, column := range columns {
|
||||||
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
columns := make([]*sqlschema.Column, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
name string
|
|
||||||
sqlDataType string
|
|
||||||
nullable bool
|
|
||||||
defaultVal *string
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&name, &nullable, &sqlDataType, &defaultVal); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
columnDefault := ""
|
columnDefault := ""
|
||||||
if defaultVal != nil {
|
if column.DefaultVal != nil {
|
||||||
columnDefault = *defaultVal
|
columnDefault = *column.DefaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
columns = append(columns, &sqlschema.Column{
|
sqlschemaColumns = append(sqlschemaColumns, &sqlschema.Column{
|
||||||
Name: sqlschema.ColumnName(name),
|
Name: sqlschema.ColumnName(column.ColumnName),
|
||||||
Nullable: nullable,
|
Nullable: column.Nullable,
|
||||||
DataType: provider.fmter.DataTypeOf(sqlDataType),
|
DataType: provider.fmter.DataTypeOf(column.SQLDataType),
|
||||||
Default: columnDefault,
|
Default: columnDefault,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -208,7 +204,7 @@ WHERE
|
|||||||
|
|
||||||
return &sqlschema.Table{
|
return &sqlschema.Table{
|
||||||
Name: tableName,
|
Name: tableName,
|
||||||
Columns: columns,
|
Columns: sqlschemaColumns,
|
||||||
PrimaryKeyConstraint: primaryKeyConstraint,
|
PrimaryKeyConstraint: primaryKeyConstraint,
|
||||||
ForeignKeyConstraints: foreignKeyConstraints,
|
ForeignKeyConstraints: foreignKeyConstraints,
|
||||||
}, uniqueConstraints, nil
|
}, uniqueConstraints, nil
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||||
export { isEventObject } from '../src/utils/isEventObject';
|
|
||||||
|
|
||||||
interface SafeNavigateOptions {
|
interface SafeNavigateOptions {
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
state?: unknown;
|
state?: unknown;
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function LogDetailInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Go to logs explorer page with the log data
|
// Go to logs explorer page with the log data
|
||||||
const handleOpenInExplorer = (event: React.MouseEvent): void => {
|
const handleOpenInExplorer = (): void => {
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
[QueryParams.activeLogId]: `"${log?.id}"`,
|
[QueryParams.activeLogId]: `"${log?.id}"`,
|
||||||
[QueryParams.startTime]: minTime?.toString() || '',
|
[QueryParams.startTime]: minTime?.toString() || '',
|
||||||
@@ -146,10 +146,7 @@ function LogDetailInner({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
safeNavigate(
|
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
|
||||||
`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`,
|
|
||||||
event,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQueryExpressionChange = useCallback(
|
const handleQueryExpressionChange = useCallback(
|
||||||
@@ -242,7 +239,7 @@ function LogDetailInner({
|
|||||||
<Button
|
<Button
|
||||||
className="open-in-explorer-btn"
|
className="open-in-explorer-btn"
|
||||||
icon={<Compass size={16} />}
|
icon={<Compass size={16} />}
|
||||||
onClick={(event: React.MouseEvent): void => handleOpenInExplorer(event)}
|
onClick={handleOpenInExplorer}
|
||||||
>
|
>
|
||||||
Open in Explorer
|
Open in Explorer
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ import UPlot from 'uplot';
|
|||||||
|
|
||||||
import { dataMatch, optionsUpdateState } from './utils';
|
import { dataMatch, optionsUpdateState } from './utils';
|
||||||
|
|
||||||
// Extended uPlot interface with custom properties
|
|
||||||
interface ExtendedUPlot extends uPlot {
|
|
||||||
_legendScrollCleanup?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UplotProps {
|
export interface UplotProps {
|
||||||
options: uPlot.Options;
|
options: uPlot.Options;
|
||||||
data: uPlot.AlignedData;
|
data: uPlot.AlignedData;
|
||||||
@@ -71,12 +66,6 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
|
|||||||
|
|
||||||
const destroy = useCallback((chart: uPlot | null) => {
|
const destroy = useCallback((chart: uPlot | null) => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
// Clean up legend scroll event listener
|
|
||||||
const extendedChart = chart as ExtendedUPlot;
|
|
||||||
if (extendedChart._legendScrollCleanup) {
|
|
||||||
extendedChart._legendScrollCleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeleteRef.current?.(chart);
|
onDeleteRef.current?.(chart);
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
|
|||||||
@@ -20,17 +20,13 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
|||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
const [action] = useComponentPermission(['new_alert_action'], user.role);
|
const [action] = useComponentPermission(['new_alert_action'], user.role);
|
||||||
|
|
||||||
const onClickEditHandler = useCallback(
|
const onClickEditHandler = useCallback((id: string) => {
|
||||||
(id: string, event?: React.MouseEvent) => {
|
history.push(
|
||||||
history.push(
|
generatePath(ROUTES.CHANNELS_EDIT, {
|
||||||
generatePath(ROUTES.CHANNELS_EDIT, {
|
channelId: id,
|
||||||
channelId: id,
|
}),
|
||||||
}),
|
);
|
||||||
event,
|
}, []);
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns: ColumnsType<Channels> = [
|
const columns: ColumnsType<Channels> = [
|
||||||
{
|
{
|
||||||
@@ -56,10 +52,7 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (id: string): JSX.Element => (
|
render: (id: string): JSX.Element => (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button onClick={(): void => onClickEditHandler(id)} type="link">
|
||||||
onClick={(event: React.MouseEvent): void => onClickEditHandler(id, event)}
|
|
||||||
type="link"
|
|
||||||
>
|
|
||||||
{t('column_channel_edit')}
|
{t('column_channel_edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Delete id={id} notifications={notifications} />
|
<Delete id={id} notifications={notifications} />
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ function AlertChannels(): JSX.Element {
|
|||||||
['add_new_channel'],
|
['add_new_channel'],
|
||||||
user.role,
|
user.role,
|
||||||
);
|
);
|
||||||
const onToggleHandler = useCallback((event?: React.MouseEvent) => {
|
const onToggleHandler = useCallback(() => {
|
||||||
history.push(ROUTES.CHANNELS_NEW, event);
|
history.push(ROUTES.CHANNELS_NEW);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { isLoading, data, error } = useQuery<
|
const { isLoading, data, error } = useQuery<
|
||||||
@@ -78,7 +78,7 @@ function AlertChannels(): JSX.Element {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={(event: React.MouseEvent): void => onToggleHandler(event)}
|
onClick={onToggleHandler}
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
disabled={!addNewChannelPermission}
|
disabled={!addNewChannelPermission}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -111,17 +111,14 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
|||||||
value: errorDetail[key as keyof GetByErrorTypeAndServicePayload],
|
value: errorDetail[key as keyof GetByErrorTypeAndServicePayload],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onClickTraceHandler = (event?: React.MouseEvent): void => {
|
const onClickTraceHandler = (): void => {
|
||||||
logEvent('Exception: Navigate to trace detail page', {
|
logEvent('Exception: Navigate to trace detail page', {
|
||||||
groupId: errorDetail?.groupID,
|
groupId: errorDetail?.groupID,
|
||||||
spanId: errorDetail.spanID,
|
spanId: errorDetail.spanID,
|
||||||
traceId: errorDetail.traceID,
|
traceId: errorDetail.traceID,
|
||||||
exceptionId: errorDetail?.errorId,
|
exceptionId: errorDetail?.errorId,
|
||||||
});
|
});
|
||||||
history.push(
|
history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`);
|
||||||
`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`,
|
|
||||||
event,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logEventCalledRef = useRef(false);
|
const logEventCalledRef = useRef(false);
|
||||||
@@ -188,10 +185,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
|||||||
|
|
||||||
<DashedContainer>
|
<DashedContainer>
|
||||||
<Typography>{t('see_trace_graph')}</Typography>
|
<Typography>{t('see_trace_graph')}</Typography>
|
||||||
<Button
|
<Button onClick={onClickTraceHandler} type="primary">
|
||||||
onClick={(event: React.MouseEvent): void => onClickTraceHandler(event)}
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
{t('see_error_in_trace_graph')}
|
{t('see_error_in_trace_graph')}
|
||||||
</Button>
|
</Button>
|
||||||
</DashedContainer>
|
</DashedContainer>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function ExplorerOptions({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onCreateAlertsHandler = useCallback(
|
const onCreateAlertsHandler = useCallback(
|
||||||
(defaultQuery: Query | null, event?: React.MouseEvent) => {
|
(defaultQuery: Query | null) => {
|
||||||
if (sourcepage === DataSource.TRACES) {
|
if (sourcepage === DataSource.TRACES) {
|
||||||
logEvent('Traces Explorer: Create alert', {
|
logEvent('Traces Explorer: Create alert', {
|
||||||
panelType,
|
panelType,
|
||||||
@@ -213,7 +213,6 @@ function ExplorerOptions({
|
|||||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||||
stringifiedQuery,
|
stringifiedQuery,
|
||||||
)}`,
|
)}`,
|
||||||
event,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -727,9 +726,7 @@ function ExplorerOptions({
|
|||||||
<Button
|
<Button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
shape="round"
|
shape="round"
|
||||||
onClick={(event: React.MouseEvent): void =>
|
onClick={(): void => onCreateAlertsHandler(query)}
|
||||||
onCreateAlertsHandler(query, event)
|
|
||||||
}
|
|
||||||
icon={<ConciergeBell size={16} />}
|
icon={<ConciergeBell size={16} />}
|
||||||
>
|
>
|
||||||
Create an Alert
|
Create an Alert
|
||||||
|
|||||||
@@ -114,10 +114,7 @@ export default function AlertRules({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const onEditHandler = (
|
const onEditHandler = (record: GettableAlert) => (): void => {
|
||||||
record: GettableAlert,
|
|
||||||
event?: React.MouseEvent,
|
|
||||||
) => (): void => {
|
|
||||||
logEvent('Homepage: Alert clicked', {
|
logEvent('Homepage: Alert clicked', {
|
||||||
ruleId: record.id,
|
ruleId: record.id,
|
||||||
ruleName: record.alert,
|
ruleName: record.alert,
|
||||||
@@ -134,7 +131,7 @@ export default function AlertRules({
|
|||||||
|
|
||||||
params.set(QueryParams.ruleId, record.id.toString());
|
params.set(QueryParams.ruleId, record.id.toString());
|
||||||
|
|
||||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, event);
|
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAlertRules = (): JSX.Element => (
|
const renderAlertRules = (): JSX.Element => (
|
||||||
@@ -146,7 +143,7 @@ export default function AlertRules({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="alert-rule-item home-data-item"
|
className="alert-rule-item home-data-item"
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
onClick={(event: React.MouseEvent): void => onEditHandler(rule, event)()}
|
onClick={onEditHandler(rule)}
|
||||||
onKeyDown={(e): void => {
|
onKeyDown={(e): void => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
onEditHandler(rule);
|
onEditHandler(rule);
|
||||||
|
|||||||
@@ -84,14 +84,14 @@ function DataSourceInfo({
|
|||||||
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Connect dataSource clicked', {});
|
logEvent('Homepage: Connect dataSource clicked', {});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activeLicense &&
|
activeLicense &&
|
||||||
activeLicense.platform === LicensePlatform.CLOUD
|
activeLicense.platform === LicensePlatform.CLOUD
|
||||||
) {
|
) {
|
||||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
|
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||||
} else {
|
} else {
|
||||||
window?.open(
|
window?.open(
|
||||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||||
|
|||||||
@@ -413,12 +413,12 @@ export default function Home(): JSX.Element {
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="active-ingestion-card-actions"
|
className="active-ingestion-card-actions"
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||||
source: 'Logs',
|
source: 'Logs',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.LOGS_EXPLORER, event);
|
history.push(ROUTES.LOGS_EXPLORER);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e): void => {
|
onKeyDown={(e): void => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -455,11 +455,11 @@ export default function Home(): JSX.Element {
|
|||||||
className="active-ingestion-card-actions"
|
className="active-ingestion-card-actions"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||||
source: 'Traces',
|
source: 'Traces',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.TRACES_EXPLORER, event);
|
history.push(ROUTES.TRACES_EXPLORER);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e): void => {
|
onKeyDown={(e): void => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -496,11 +496,11 @@ export default function Home(): JSX.Element {
|
|||||||
className="active-ingestion-card-actions"
|
className="active-ingestion-card-actions"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||||
source: 'Metrics',
|
source: 'Metrics',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.METRICS_EXPLORER, event);
|
history.push(ROUTES.METRICS_EXPLORER);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e): void => {
|
onKeyDown={(e): void => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -550,11 +550,11 @@ export default function Home(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
icon={<Wrench size={14} />}
|
icon={<Wrench size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Explore clicked', {
|
logEvent('Homepage: Explore clicked', {
|
||||||
source: 'Logs',
|
source: 'Logs',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.LOGS_EXPLORER, event);
|
history.push(ROUTES.LOGS_EXPLORER);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open Logs Explorer
|
Open Logs Explorer
|
||||||
@@ -564,11 +564,11 @@ export default function Home(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
icon={<Wrench size={14} />}
|
icon={<Wrench size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Explore clicked', {
|
logEvent('Homepage: Explore clicked', {
|
||||||
source: 'Traces',
|
source: 'Traces',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.TRACES_EXPLORER, event);
|
history.push(ROUTES.TRACES_EXPLORER);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open Traces Explorer
|
Open Traces Explorer
|
||||||
@@ -578,11 +578,11 @@ export default function Home(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
icon={<Wrench size={14} />}
|
icon={<Wrench size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Explore clicked', {
|
logEvent('Homepage: Explore clicked', {
|
||||||
source: 'Metrics',
|
source: 'Metrics',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.METRICS_EXPLORER_EXPLORER, event);
|
history.push(ROUTES.METRICS_EXPLORER_EXPLORER);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open Metrics Explorer
|
Open Metrics Explorer
|
||||||
@@ -619,11 +619,11 @@ export default function Home(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
icon={<Plus size={14} />}
|
icon={<Plus size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Explore clicked', {
|
logEvent('Homepage: Explore clicked', {
|
||||||
source: 'Dashboards',
|
source: 'Dashboards',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.ALL_DASHBOARD, event);
|
history.push(ROUTES.ALL_DASHBOARD);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create dashboard
|
Create dashboard
|
||||||
@@ -661,11 +661,11 @@ export default function Home(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
icon={<Plus size={14} />}
|
icon={<Plus size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Explore clicked', {
|
logEvent('Homepage: Explore clicked', {
|
||||||
source: 'Alerts',
|
source: 'Alerts',
|
||||||
});
|
});
|
||||||
history.push(ROUTES.ALERTS_NEW, event);
|
history.push(ROUTES.ALERTS_NEW);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create an alert
|
Create an alert
|
||||||
|
|||||||
@@ -86,18 +86,18 @@ function HomeChecklist({
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Welcome Checklist: Get started clicked', {
|
logEvent('Welcome Checklist: Get started clicked', {
|
||||||
step: item.id,
|
step: item.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
|
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
|
||||||
history.push(item.toRoute || '', event);
|
history.push(item.toRoute || '');
|
||||||
} else if (
|
} else if (
|
||||||
activeLicense &&
|
activeLicense &&
|
||||||
activeLicense.platform === LicensePlatform.CLOUD
|
activeLicense.platform === LicensePlatform.CLOUD
|
||||||
) {
|
) {
|
||||||
history.push(item.toRoute || '', event);
|
history.push(item.toRoute || '');
|
||||||
} else {
|
} else {
|
||||||
window?.open(
|
window?.open(
|
||||||
item.docsLink || '',
|
item.docsLink || '',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const EmptyState = memo(
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Get Started clicked', {
|
logEvent('Homepage: Get Started clicked', {
|
||||||
source: 'Service Metrics',
|
source: 'Service Metrics',
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ const EmptyState = memo(
|
|||||||
activeLicenseV3 &&
|
activeLicenseV3 &&
|
||||||
activeLicenseV3.platform === LicensePlatform.CLOUD
|
activeLicenseV3.platform === LicensePlatform.CLOUD
|
||||||
) {
|
) {
|
||||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
|
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||||
} else {
|
} else {
|
||||||
window?.open(
|
window?.open(
|
||||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||||
@@ -116,7 +116,7 @@ const ServicesListTable = memo(
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
}: {
|
}: {
|
||||||
services: ServicesList[];
|
services: ServicesList[];
|
||||||
onRowClick: (record: ServicesList, event: React.MouseEvent) => void;
|
onRowClick: (record: ServicesList) => void;
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
<div className="services-list-container home-data-item-container metrics-services-list">
|
<div className="services-list-container home-data-item-container metrics-services-list">
|
||||||
<div className="services-list">
|
<div className="services-list">
|
||||||
@@ -125,8 +125,8 @@ const ServicesListTable = memo(
|
|||||||
dataSource={services}
|
dataSource={services}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
className="services-table"
|
className="services-table"
|
||||||
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
|
onRow={(record): { onClick: () => void } => ({
|
||||||
onClick: (event: React.MouseEvent): void => onRowClick(record, event),
|
onClick: (): void => onRowClick(record),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,11 +284,11 @@ function ServiceMetrics({
|
|||||||
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
|
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(record: ServicesList, event: React.MouseEvent) => {
|
(record: ServicesList) => {
|
||||||
logEvent('Homepage: Service clicked', {
|
logEvent('Homepage: Service clicked', {
|
||||||
serviceName: record.serviceName,
|
serviceName: record.serviceName,
|
||||||
});
|
});
|
||||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
|
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
|
||||||
},
|
},
|
||||||
[safeNavigate],
|
[safeNavigate],
|
||||||
);
|
);
|
||||||
@@ -333,12 +333,7 @@ function ServiceMetrics({
|
|||||||
)}
|
)}
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{servicesExist ? (
|
{servicesExist ? (
|
||||||
<ServicesListTable
|
<ServicesListTable services={top5Services} onRowClick={handleRowClick} />
|
||||||
services={top5Services}
|
|
||||||
onRowClick={(record: ServicesList, event: React.MouseEvent): void =>
|
|
||||||
handleRowClick(record, event)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyState user={user} activeLicenseV3={activeLicense} />
|
<EmptyState user={user} activeLicenseV3={activeLicense} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function ServiceTraces({
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
className="periscope-btn secondary"
|
className="periscope-btn secondary"
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
logEvent('Homepage: Get Started clicked', {
|
logEvent('Homepage: Get Started clicked', {
|
||||||
source: 'Service Traces',
|
source: 'Service Traces',
|
||||||
});
|
});
|
||||||
@@ -127,7 +127,7 @@ export default function ServiceTraces({
|
|||||||
activeLicense &&
|
activeLicense &&
|
||||||
activeLicense.platform === LicensePlatform.CLOUD
|
activeLicense.platform === LicensePlatform.CLOUD
|
||||||
) {
|
) {
|
||||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD, event);
|
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||||
} else {
|
} else {
|
||||||
window?.open(
|
window?.open(
|
||||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||||
@@ -172,13 +172,13 @@ export default function ServiceTraces({
|
|||||||
dataSource={top5Services}
|
dataSource={top5Services}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
className="services-table"
|
className="services-table"
|
||||||
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
|
onRow={(record): { onClick: () => void } => ({
|
||||||
onClick: (event: React.MouseEvent): void => {
|
onClick: (): void => {
|
||||||
logEvent('Homepage: Service clicked', {
|
logEvent('Homepage: Service clicked', {
|
||||||
serviceName: record.serviceName,
|
serviceName: record.serviceName,
|
||||||
});
|
});
|
||||||
|
|
||||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
|
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export function AlertsEmptyState(): JSX.Element {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const onClickNewAlertHandler = useCallback((event: React.MouseEvent) => {
|
const onClickNewAlertHandler = useCallback(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
history.push(ROUTES.ALERTS_NEW, event);
|
history.push(ROUTES.ALERTS_NEW);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,9 +70,7 @@ export function AlertsEmptyState(): JSX.Element {
|
|||||||
<div className="action-container">
|
<div className="action-container">
|
||||||
<Button
|
<Button
|
||||||
className="add-alert-btn"
|
className="add-alert-btn"
|
||||||
onClick={(event: React.MouseEvent): void =>
|
onClick={onClickNewAlertHandler}
|
||||||
onClickNewAlertHandler(event)
|
|
||||||
}
|
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
disabled={!addNewAlert}
|
disabled={!addNewAlert}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const templatesList: DashboardTemplate[] = [
|
|||||||
|
|
||||||
interface DashboardTemplatesModalProps {
|
interface DashboardTemplatesModalProps {
|
||||||
showNewDashboardTemplatesModal: boolean;
|
showNewDashboardTemplatesModal: boolean;
|
||||||
onCreateNewDashboard: (event: React.MouseEvent) => void;
|
onCreateNewDashboard: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +204,7 @@ export default function DashboardTemplatesModal({
|
|||||||
type="primary"
|
type="primary"
|
||||||
className="periscope-btn primary"
|
className="periscope-btn primary"
|
||||||
icon={<Plus size={14} />}
|
icon={<Plus size={14} />}
|
||||||
onClick={(event: React.MouseEvent): void =>
|
onClick={onCreateNewDashboard}
|
||||||
onCreateNewDashboard(event)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
New dashboard
|
New dashboard
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -282,39 +282,35 @@ function DashboardsList(): JSX.Element {
|
|||||||
refetchDashboardList,
|
refetchDashboardList,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const onNewDashboardHandler = useCallback(
|
const onNewDashboardHandler = useCallback(async () => {
|
||||||
async (event: React.MouseEvent) => {
|
try {
|
||||||
try {
|
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
setNewDashboardState({
|
||||||
setNewDashboardState({
|
...newDashboardState,
|
||||||
...newDashboardState,
|
loading: true,
|
||||||
loading: true,
|
});
|
||||||
});
|
const response = await createDashboard({
|
||||||
const response = await createDashboard({
|
title: t('new_dashboard_title', {
|
||||||
title: t('new_dashboard_title', {
|
ns: 'dashboard',
|
||||||
ns: 'dashboard',
|
}),
|
||||||
}),
|
uploadedGrafana: false,
|
||||||
uploadedGrafana: false,
|
version: ENTITY_VERSION_V5,
|
||||||
version: ENTITY_VERSION_V5,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
safeNavigate(
|
safeNavigate(
|
||||||
generatePath(ROUTES.DASHBOARD, {
|
generatePath(ROUTES.DASHBOARD, {
|
||||||
dashboardId: response.data.id,
|
dashboardId: response.data.id,
|
||||||
}),
|
}),
|
||||||
event,
|
);
|
||||||
);
|
} catch (error) {
|
||||||
} catch (error) {
|
showErrorModal(error as APIError);
|
||||||
showErrorModal(error as APIError);
|
setNewDashboardState({
|
||||||
setNewDashboardState({
|
...newDashboardState,
|
||||||
...newDashboardState,
|
error: true,
|
||||||
error: true,
|
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
|
||||||
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
|
});
|
||||||
});
|
}
|
||||||
}
|
}, [newDashboardState, safeNavigate, showErrorModal, t]);
|
||||||
},
|
|
||||||
[newDashboardState, safeNavigate, showErrorModal, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onModalHandler = (uploadedGrafana: boolean): void => {
|
const onModalHandler = (uploadedGrafana: boolean): void => {
|
||||||
logEvent('Dashboard List: Import JSON clicked', {});
|
logEvent('Dashboard List: Import JSON clicked', {});
|
||||||
@@ -643,8 +639,8 @@ function DashboardsList(): JSX.Element {
|
|||||||
label: (
|
label: (
|
||||||
<div
|
<div
|
||||||
className="create-dashboard-menu-item"
|
className="create-dashboard-menu-item"
|
||||||
onClick={(event: React.MouseEvent): void => {
|
onClick={(): void => {
|
||||||
onNewDashboardHandler(event);
|
onNewDashboardHandler();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutGrid size={14} /> Create dashboard
|
<LayoutGrid size={14} /> Create dashboard
|
||||||
@@ -931,9 +927,7 @@ function DashboardsList(): JSX.Element {
|
|||||||
|
|
||||||
<DashboardTemplatesModal
|
<DashboardTemplatesModal
|
||||||
showNewDashboardTemplatesModal={showNewDashboardTemplatesModal}
|
showNewDashboardTemplatesModal={showNewDashboardTemplatesModal}
|
||||||
onCreateNewDashboard={(event: React.MouseEvent): Promise<void> =>
|
onCreateNewDashboard={onNewDashboardHandler}
|
||||||
onNewDashboardHandler(event)
|
|
||||||
}
|
|
||||||
onCancel={(): void => {
|
onCancel={(): void => {
|
||||||
setShowNewDashboardTemplatesModal(false);
|
setShowNewDashboardTemplatesModal(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ function Application(): JSX.Element {
|
|||||||
timestamp: number,
|
timestamp: number,
|
||||||
apmToTraceQuery: Query,
|
apmToTraceQuery: Query,
|
||||||
isViewLogsClicked?: boolean,
|
isViewLogsClicked?: boolean,
|
||||||
event?: React.MouseEvent,
|
|
||||||
): (() => void) => (): void => {
|
): (() => void) => (): void => {
|
||||||
const endTime = secondsToMilliseconds(timestamp);
|
const endTime = secondsToMilliseconds(timestamp);
|
||||||
const startTime = secondsToMilliseconds(timestamp - stepInterval);
|
const startTime = secondsToMilliseconds(timestamp - stepInterval);
|
||||||
@@ -260,7 +259,7 @@ function Application(): JSX.Element {
|
|||||||
queryString,
|
queryString,
|
||||||
);
|
);
|
||||||
|
|
||||||
history.push(newPath, event);
|
history.push(newPath);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[stepInterval],
|
[stepInterval],
|
||||||
@@ -320,16 +319,14 @@ function Application(): JSX.Element {
|
|||||||
type="default"
|
type="default"
|
||||||
size="small"
|
size="small"
|
||||||
id="Rate_button"
|
id="Rate_button"
|
||||||
onClick={(event: React.MouseEvent): void =>
|
onClick={onViewTracePopupClick({
|
||||||
onViewTracePopupClick({
|
servicename,
|
||||||
servicename,
|
selectedTraceTags,
|
||||||
selectedTraceTags,
|
timestamp: selectedTimeStamp,
|
||||||
timestamp: selectedTimeStamp,
|
apmToTraceQuery,
|
||||||
apmToTraceQuery,
|
stepInterval,
|
||||||
stepInterval,
|
safeNavigate,
|
||||||
safeNavigate,
|
})}
|
||||||
})(event)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
View Traces
|
View Traces
|
||||||
</Button>
|
</Button>
|
||||||
@@ -373,12 +370,15 @@ function Application(): JSX.Element {
|
|||||||
<ColErrorContainer>
|
<ColErrorContainer>
|
||||||
<GraphControlsPanel
|
<GraphControlsPanel
|
||||||
id="Error_button"
|
id="Error_button"
|
||||||
onViewLogsClick={(event: React.MouseEvent): void =>
|
onViewLogsClick={onErrorTrackHandler(
|
||||||
onErrorTrackHandler(selectedTimeStamp, logErrorQuery, true, event)()
|
selectedTimeStamp,
|
||||||
}
|
logErrorQuery,
|
||||||
onViewTracesClick={(event: React.MouseEvent): void =>
|
true,
|
||||||
onErrorTrackHandler(selectedTimeStamp, errorTrackQuery, false, event)()
|
)}
|
||||||
}
|
onViewTracesClick={onErrorTrackHandler(
|
||||||
|
selectedTimeStamp,
|
||||||
|
errorTrackQuery,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TopLevelOperation
|
<TopLevelOperation
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Binoculars, DraftingCompass, ScrollText } from 'lucide-react';
|
|||||||
|
|
||||||
interface GraphControlsPanelProps {
|
interface GraphControlsPanelProps {
|
||||||
id: string;
|
id: string;
|
||||||
onViewLogsClick?: (event: React.MouseEvent) => void;
|
onViewLogsClick?: () => void;
|
||||||
onViewTracesClick: (event: React.MouseEvent) => void;
|
onViewTracesClick: () => void;
|
||||||
onViewAPIMonitoringClick?: (event: React.MouseEvent) => void;
|
onViewAPIMonitoringClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GraphControlsPanel({
|
function GraphControlsPanel({
|
||||||
@@ -23,7 +23,7 @@ function GraphControlsPanel({
|
|||||||
type="link"
|
type="link"
|
||||||
icon={<DraftingCompass size={14} />}
|
icon={<DraftingCompass size={14} />}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(event: React.MouseEvent): void => onViewTracesClick(event)}
|
onClick={onViewTracesClick}
|
||||||
style={{ color: Color.BG_VANILLA_100 }}
|
style={{ color: Color.BG_VANILLA_100 }}
|
||||||
>
|
>
|
||||||
View traces
|
View traces
|
||||||
@@ -33,7 +33,7 @@ function GraphControlsPanel({
|
|||||||
type="link"
|
type="link"
|
||||||
icon={<ScrollText size={14} />}
|
icon={<ScrollText size={14} />}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(event: React.MouseEvent): void => onViewLogsClick(event)}
|
onClick={onViewLogsClick}
|
||||||
style={{ color: Color.BG_VANILLA_100 }}
|
style={{ color: Color.BG_VANILLA_100 }}
|
||||||
>
|
>
|
||||||
View logs
|
View logs
|
||||||
@@ -44,9 +44,7 @@ function GraphControlsPanel({
|
|||||||
type="link"
|
type="link"
|
||||||
icon={<Binoculars size={14} />}
|
icon={<Binoculars size={14} />}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(event: React.MouseEvent): void =>
|
onClick={onViewAPIMonitoringClick}
|
||||||
onViewAPIMonitoringClick(event)
|
|
||||||
}
|
|
||||||
style={{ color: Color.BG_VANILLA_100 }}
|
style={{ color: Color.BG_VANILLA_100 }}
|
||||||
>
|
>
|
||||||
View External APIs
|
View External APIs
|
||||||
|
|||||||
@@ -103,27 +103,23 @@ function ServiceOverview({
|
|||||||
<>
|
<>
|
||||||
<GraphControlsPanel
|
<GraphControlsPanel
|
||||||
id="Service_button"
|
id="Service_button"
|
||||||
onViewLogsClick={(event: React.MouseEvent): void =>
|
onViewLogsClick={onViewTracePopupClick({
|
||||||
onViewTracePopupClick({
|
servicename,
|
||||||
servicename,
|
selectedTraceTags,
|
||||||
selectedTraceTags,
|
timestamp: selectedTimeStamp,
|
||||||
timestamp: selectedTimeStamp,
|
apmToTraceQuery: apmToLogQuery,
|
||||||
apmToTraceQuery: apmToLogQuery,
|
isViewLogsClicked: true,
|
||||||
isViewLogsClicked: true,
|
stepInterval,
|
||||||
stepInterval,
|
safeNavigate,
|
||||||
safeNavigate,
|
})}
|
||||||
})(event)
|
onViewTracesClick={onViewTracePopupClick({
|
||||||
}
|
servicename,
|
||||||
onViewTracesClick={(event: React.MouseEvent): void =>
|
selectedTraceTags,
|
||||||
onViewTracePopupClick({
|
timestamp: selectedTimeStamp,
|
||||||
servicename,
|
apmToTraceQuery,
|
||||||
selectedTraceTags,
|
stepInterval,
|
||||||
timestamp: selectedTimeStamp,
|
safeNavigate,
|
||||||
apmToTraceQuery,
|
})}
|
||||||
stepInterval,
|
|
||||||
safeNavigate,
|
|
||||||
})(event)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Card data-testid="service_latency">
|
<Card data-testid="service_latency">
|
||||||
<GraphContainer>
|
<GraphContainer>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface OnViewTracePopupClickProps {
|
|||||||
apmToTraceQuery: Query;
|
apmToTraceQuery: Query;
|
||||||
isViewLogsClicked?: boolean;
|
isViewLogsClicked?: boolean;
|
||||||
stepInterval?: number;
|
stepInterval?: number;
|
||||||
safeNavigate: (url: string, event?: React.MouseEvent) => void;
|
safeNavigate: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OnViewAPIMonitoringPopupClickProps {
|
interface OnViewAPIMonitoringPopupClickProps {
|
||||||
@@ -52,7 +52,7 @@ interface OnViewAPIMonitoringPopupClickProps {
|
|||||||
domainName: string;
|
domainName: string;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
|
|
||||||
safeNavigate: (url: string, event?: React.MouseEvent) => void;
|
safeNavigate: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateExplorerPath(
|
export function generateExplorerPath(
|
||||||
@@ -93,8 +93,8 @@ export function onViewTracePopupClick({
|
|||||||
isViewLogsClicked,
|
isViewLogsClicked,
|
||||||
stepInterval,
|
stepInterval,
|
||||||
safeNavigate,
|
safeNavigate,
|
||||||
}: OnViewTracePopupClickProps): (event: React.MouseEvent) => void {
|
}: OnViewTracePopupClickProps): VoidFunction {
|
||||||
return (event: React.MouseEvent): void => {
|
return (): void => {
|
||||||
const endTime = secondsToMilliseconds(timestamp);
|
const endTime = secondsToMilliseconds(timestamp);
|
||||||
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
|
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
|
||||||
|
|
||||||
@@ -118,11 +118,7 @@ export function onViewTracePopupClick({
|
|||||||
queryString,
|
queryString,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event) {
|
safeNavigate(newPath);
|
||||||
safeNavigate(newPath, event);
|
|
||||||
} else {
|
|
||||||
safeNavigate(newPath);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +149,8 @@ export function onViewAPIMonitoringPopupClick({
|
|||||||
isError,
|
isError,
|
||||||
stepInterval,
|
stepInterval,
|
||||||
safeNavigate,
|
safeNavigate,
|
||||||
}: OnViewAPIMonitoringPopupClickProps): (event: React.MouseEvent) => void {
|
}: OnViewAPIMonitoringPopupClickProps): VoidFunction {
|
||||||
return (event: React.MouseEvent): void => {
|
return (): void => {
|
||||||
const endTime = timestamp + (stepInterval || 60);
|
const endTime = timestamp + (stepInterval || 60);
|
||||||
const startTime = timestamp - (stepInterval || 60);
|
const startTime = timestamp - (stepInterval || 60);
|
||||||
const filters = {
|
const filters = {
|
||||||
@@ -194,11 +190,7 @@ export function onViewAPIMonitoringPopupClick({
|
|||||||
filters,
|
filters,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event) {
|
safeNavigate(newPath);
|
||||||
safeNavigate(newPath, event);
|
|
||||||
} else {
|
|
||||||
safeNavigate(newPath);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
|||||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||||
|
|
||||||
const mockSafeNavigate = jest.fn();
|
const mockSafeNavigate = jest.fn();
|
||||||
jest.mock('hooks/useSafeNavigate', () => {
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
const actual = jest.requireActual('hooks/useSafeNavigate');
|
useSafeNavigate: (): any => ({
|
||||||
return {
|
safeNavigate: mockSafeNavigate,
|
||||||
...actual,
|
}),
|
||||||
useSafeNavigate: (): any => ({
|
}));
|
||||||
safeNavigate: mockSafeNavigate,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockSetQuery = jest.fn();
|
const mockSetQuery = jest.fn();
|
||||||
const mockUrlQuery = {
|
const mockUrlQuery = {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function NoLogs({
|
|||||||
} else {
|
} else {
|
||||||
link = ROUTES.GET_STARTED_LOGS_MANAGEMENT;
|
link = ROUTES.GET_STARTED_LOGS_MANAGEMENT;
|
||||||
}
|
}
|
||||||
history.push(link, e);
|
history.push(link);
|
||||||
} else if (dataSource === 'traces') {
|
} else if (dataSource === 'traces') {
|
||||||
window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank');
|
window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank');
|
||||||
} else if (dataSource === DataSource.METRICS) {
|
} else if (dataSource === DataSource.METRICS) {
|
||||||
@@ -59,12 +59,7 @@ export default function NoLogs({
|
|||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography.Link
|
<Typography.Link className="send-logs-link" onClick={handleLinkClick}>
|
||||||
className="send-logs-link"
|
|
||||||
onClick={(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void =>
|
|
||||||
handleLinkClick(e)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Sending {dataSource} to SigNoz <ArrowUpRight size={16} />
|
Sending {dataSource} to SigNoz <ArrowUpRight size={16} />
|
||||||
</Typography.Link>
|
</Typography.Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Popover, Spin, Tooltip } from 'antd';
|
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||||
|
import cx from 'classnames';
|
||||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||||
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||||
import {
|
import {
|
||||||
@@ -124,7 +125,7 @@ export default function AttributeActions({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="action-btn">
|
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||||
<Button
|
<Button
|
||||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding-block: 12px;
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -25,8 +25,10 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 2px 12px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
background-color: var(--bg-slate-500);
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -81,22 +83,23 @@
|
|||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
|
&--is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: none;
|
border-color: var(--bg-slate-400);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--bg-slate-400);
|
background: var(--bg-slate-500);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg-slate-400);
|
background-color: var(--bg-slate-400) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +145,7 @@
|
|||||||
.ant-popover-inner {
|
.ant-popover-inner {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
background: var(--bg-slate-500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +153,9 @@
|
|||||||
.attributes-corner {
|
.attributes-corner {
|
||||||
.attributes-container {
|
.attributes-container {
|
||||||
.item {
|
.item {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
.item-key {
|
.item-key {
|
||||||
color: var(--bg-ink-100);
|
color: var(--bg-ink-100);
|
||||||
}
|
}
|
||||||
@@ -163,8 +170,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
background: var(--bg-vanilla-200);
|
background: var(--bg-vanilla-200);
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,52 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 10px 12px;
|
padding-block: 12px;
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
padding: 2px 12px;
|
||||||
|
|
||||||
|
&--interactive {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-slate-500);
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: none;
|
||||||
|
&--is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-color: var(--bg-slate-400);
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-slate-500);
|
||||||
|
padding: 4px;
|
||||||
|
gap: 3px;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.attribute-key {
|
.attribute-key {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
@@ -238,6 +278,18 @@
|
|||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.value-wrapper {
|
.value-wrapper {
|
||||||
border: 1px solid var(--bg-vanilla-300);
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
background: var(--bg-vanilla-300);
|
background: var(--bg-vanilla-300);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Attributes from './Attributes/Attributes';
|
|||||||
import { RelatedSignalsViews } from './constants';
|
import { RelatedSignalsViews } from './constants';
|
||||||
import Events from './Events/Events';
|
import Events from './Events/Events';
|
||||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||||
|
import SpanFieldActions from './SpanFieldActions/SpanFieldActions';
|
||||||
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
||||||
|
|
||||||
interface ISpanDetailsDrawerProps {
|
interface ISpanDetailsDrawerProps {
|
||||||
@@ -141,7 +142,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
{selectedSpan && !isSpanDetailsDocked && (
|
{selectedSpan && !isSpanDetailsDocked && (
|
||||||
<>
|
<>
|
||||||
<section className="description">
|
<section className="description">
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">span name</Typography.Text>
|
<Typography.Text className="attribute-key">span name</Typography.Text>
|
||||||
<Tooltip title={selectedSpan.name}>
|
<Tooltip title={selectedSpan.name}>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
@@ -150,14 +151,22 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="span name"
|
||||||
|
fieldValue={selectedSpan.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">span id</Typography.Text>
|
<Typography.Text className="attribute-key">span id</Typography.Text>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
<Typography.Text className="attribute-value">
|
<Typography.Text className="attribute-value">
|
||||||
{selectedSpan.spanId}
|
{selectedSpan.spanId}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="span id"
|
||||||
|
fieldValue={selectedSpan.spanId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item">
|
||||||
<Typography.Text className="attribute-key">start time</Typography.Text>
|
<Typography.Text className="attribute-key">start time</Typography.Text>
|
||||||
@@ -167,15 +176,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">duration</Typography.Text>
|
<Typography.Text className="attribute-key">duration</Typography.Text>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
<Typography.Text className="attribute-value">
|
<Typography.Text className="attribute-value">
|
||||||
{getYAxisFormattedValue(`${selectedSpan.durationNano}`, 'ns')}
|
{getYAxisFormattedValue(`${selectedSpan.durationNano}`, 'ns')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="duration"
|
||||||
|
fieldValue={selectedSpan.durationNano.toString()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">service</Typography.Text>
|
<Typography.Text className="attribute-key">service</Typography.Text>
|
||||||
<div className="service">
|
<div className="service">
|
||||||
<div className="dot" style={{ backgroundColor: color }} />
|
<div className="dot" style={{ backgroundColor: color }} />
|
||||||
@@ -187,16 +200,24 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="service"
|
||||||
|
fieldValue={selectedSpan.serviceName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">span kind</Typography.Text>
|
<Typography.Text className="attribute-key">span kind</Typography.Text>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
<Typography.Text className="attribute-value">
|
<Typography.Text className="attribute-value">
|
||||||
{selectedSpan.spanKind}
|
{selectedSpan.spanKind}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="span kind"
|
||||||
|
fieldValue={selectedSpan.spanKind}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">
|
<Typography.Text className="attribute-key">
|
||||||
status code string
|
status code string
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -205,10 +226,14 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
{selectedSpan.statusCodeString}
|
{selectedSpan.statusCodeString}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="status code string"
|
||||||
|
fieldValue={selectedSpan.statusCodeString}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedSpan.statusMessage && (
|
{selectedSpan.statusMessage && (
|
||||||
<div className="item">
|
<div className="item item--interactive">
|
||||||
<Typography.Text className="attribute-key">
|
<Typography.Text className="attribute-key">
|
||||||
status message
|
status message
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -217,6 +242,10 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
{selectedSpan.statusMessage}
|
{selectedSpan.statusMessage}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
<SpanFieldActions
|
||||||
|
fieldDisplayName="status message"
|
||||||
|
fieldValue={selectedSpan.statusMessage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="item">
|
<div className="item">
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||||
|
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||||
|
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
// Field mapping from display names to actual span property keys
|
||||||
|
const SPAN_FIELD_MAPPING: Record<string, string> = {
|
||||||
|
'span name': 'name',
|
||||||
|
'span id': 'span_id',
|
||||||
|
duration: 'durationNano',
|
||||||
|
service: 'serviceName',
|
||||||
|
'span kind': 'spanKind',
|
||||||
|
'status code string': 'statusCodeString',
|
||||||
|
'status message': 'statusMessage',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpanFieldActionsProps {
|
||||||
|
fieldDisplayName: string;
|
||||||
|
fieldValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpanFieldActions({
|
||||||
|
fieldDisplayName,
|
||||||
|
fieldValue,
|
||||||
|
}: SpanFieldActionsProps): JSX.Element {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||||
|
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const { onAddToQuery, onCopyFieldName, onCopyFieldValue } = useTraceActions();
|
||||||
|
|
||||||
|
const mappedFieldKey =
|
||||||
|
SPAN_FIELD_MAPPING[fieldDisplayName] || fieldDisplayName;
|
||||||
|
|
||||||
|
const handleFilter = useCallback(
|
||||||
|
async (operator: string, isFilterIn: boolean): Promise<void> => {
|
||||||
|
const isLoading = isFilterIn ? isFilterInLoading : isFilterOutLoading;
|
||||||
|
const setLoading = isFilterIn ? setIsFilterInLoading : setIsFilterOutLoading;
|
||||||
|
|
||||||
|
if (!onAddToQuery || isLoading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onAddToQuery(mappedFieldKey, fieldValue, operator);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onAddToQuery,
|
||||||
|
mappedFieldKey,
|
||||||
|
fieldValue,
|
||||||
|
isFilterInLoading,
|
||||||
|
isFilterOutLoading,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterIn = useCallback(() => handleFilter(OPERATORS['='], true), [
|
||||||
|
handleFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleFilterOut = useCallback(
|
||||||
|
() => handleFilter(OPERATORS['!='], false),
|
||||||
|
[handleFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
(copyFn: ((value: string) => void) | undefined, value: string): void => {
|
||||||
|
if (copyFn) {
|
||||||
|
copyFn(value);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyFieldName = useCallback(
|
||||||
|
() => handleCopy(onCopyFieldName, mappedFieldKey),
|
||||||
|
[handleCopy, onCopyFieldName, mappedFieldKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyFieldValue = useCallback(
|
||||||
|
() => handleCopy(onCopyFieldValue, fieldValue),
|
||||||
|
[fieldValue, handleCopy, onCopyFieldValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moreActionsContent = (
|
||||||
|
<div className="attribute-actions-menu">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyFieldName}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Copy Field Name
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyFieldValue}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Copy Field Value
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||||
|
<Tooltip title="Filter for value">
|
||||||
|
<Button
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
aria-label="Filter for value"
|
||||||
|
disabled={isFilterInLoading}
|
||||||
|
icon={
|
||||||
|
isFilterInLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleFilterIn}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Filter out value">
|
||||||
|
<Button
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
aria-label="Filter out value"
|
||||||
|
disabled={isFilterOutLoading}
|
||||||
|
icon={
|
||||||
|
isFilterOutLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleFilterOut}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
arrow={false}
|
||||||
|
content={moreActionsContent}
|
||||||
|
rootClassName="attribute-actions-content"
|
||||||
|
trigger="hover"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<Ellipsis size={14} />}
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { AppProvider } from 'providers/App/App';
|
||||||
|
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||||
|
import { MemoryRouter, Route } from 'react-router-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
|
||||||
|
// Mock external dependencies following the same pattern as AttributeActions tests
|
||||||
|
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||||
|
const mockNotifications = {
|
||||||
|
success: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockSetCopy = jest.fn();
|
||||||
|
const mockQueryClient = {
|
||||||
|
fetchQuery: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the hooks - same as AttributeActions test setup
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
aggregateOperator: 'count',
|
||||||
|
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||||
|
filters: { items: [], op: 'AND' },
|
||||||
|
filter: { expression: '' },
|
||||||
|
groupBy: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useNotifications', () => ({
|
||||||
|
useNotifications: (): any => ({ notifications: mockNotifications }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-use', () => ({
|
||||||
|
...jest.requireActual('react-use'),
|
||||||
|
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-query', () => ({
|
||||||
|
...jest.requireActual('react-query'),
|
||||||
|
useQueryClient: (): any => mockQueryClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
|
||||||
|
|
||||||
|
// Mock the API response for getAggregateKeys
|
||||||
|
const mockAggregateKeysResponse = {
|
||||||
|
payload: {
|
||||||
|
attributeKeys: [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'tag',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serviceName',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'resource',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spanKind',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'tag',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statusCodeString',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'tag',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'span_id',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'tag',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'durationNano',
|
||||||
|
dataType: 'number',
|
||||||
|
type: 'tag',
|
||||||
|
isColumn: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create realistic mock span data for testing
|
||||||
|
const createMockSpan = (overrides: Partial<Span> = {}): Span => ({
|
||||||
|
spanId: '28a8a67365d0bd8b',
|
||||||
|
traceId: '000000000000000071dc9b0a338729b4',
|
||||||
|
name: 'HTTP GET /api/users',
|
||||||
|
timestamp: 1699872000000000,
|
||||||
|
durationNano: 150000000,
|
||||||
|
serviceName: 'frontend-service',
|
||||||
|
spanKind: 'server',
|
||||||
|
statusCodeString: 'OK',
|
||||||
|
statusMessage: '',
|
||||||
|
tagMap: {
|
||||||
|
'http.method': 'GET',
|
||||||
|
'http.url': '/api/users?page=1',
|
||||||
|
},
|
||||||
|
event: [],
|
||||||
|
references: [],
|
||||||
|
hasError: false,
|
||||||
|
rootSpanId: '',
|
||||||
|
parentSpanId: '',
|
||||||
|
kind: 0,
|
||||||
|
rootName: '',
|
||||||
|
hasChildren: false,
|
||||||
|
hasSibling: false,
|
||||||
|
subTreeNodeCount: 0,
|
||||||
|
level: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RenderResult {
|
||||||
|
user: ReturnType<typeof userEvent.setup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSpanDetailsDrawer = (
|
||||||
|
span: Span = createMockSpan(),
|
||||||
|
): RenderResult => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<AppProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<Route>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={span}
|
||||||
|
traceStartTime={span.timestamp}
|
||||||
|
traceEndTime={span.timestamp + span.durationNano}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppProvider>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { user };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SpanFieldActions User Flow Tests', () => {
|
||||||
|
describe('Primary Filter Flow', () => {
|
||||||
|
it('should allow user to filter for span name value and navigate to traces explorer', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
name: 'GET /api/orders',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the span name displayed
|
||||||
|
expect(screen.getByText('span name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GET /api/orders')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the span name field item
|
||||||
|
const spanNameItem = screen.getByText('span name').closest('.item');
|
||||||
|
expect(spanNameItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers over the span name field to reveal action buttons
|
||||||
|
await user.hover(spanNameItem!);
|
||||||
|
|
||||||
|
// Action buttons should appear on hover
|
||||||
|
const actionButtons = spanNameItem!.querySelector('.action-btn');
|
||||||
|
expect(actionButtons).toBeInTheDocument();
|
||||||
|
|
||||||
|
const filterForButton = spanNameItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(filterForButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User clicks "Filter for value" button
|
||||||
|
await user.click(filterForButton);
|
||||||
|
|
||||||
|
// Verify navigation to traces explorer with correct filter
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dataSource: 'traces',
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'name' }),
|
||||||
|
op: '=',
|
||||||
|
value: 'GET /api/orders',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow user to filter for service name with proper field mapping', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
serviceName: 'payment-service',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the service displayed
|
||||||
|
expect(screen.getByText('service')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('payment-service')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the service field item
|
||||||
|
const serviceItem = screen.getByText('service').closest('.item');
|
||||||
|
expect(serviceItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers and clicks filter for
|
||||||
|
await user.hover(serviceItem!);
|
||||||
|
const filterForButton = serviceItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(filterForButton);
|
||||||
|
|
||||||
|
// Verify correct field mapping: "service" display name → "serviceName" query key
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dataSource: 'traces',
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'serviceName' }),
|
||||||
|
op: '=',
|
||||||
|
value: 'payment-service',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filter Out Flow', () => {
|
||||||
|
it('should allow user to exclude span kind value and navigate to traces explorer', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
spanKind: 'client',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the span kind displayed
|
||||||
|
expect(screen.getByText('span kind')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('client')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the span kind field item
|
||||||
|
const spanKindItem = screen.getByText('span kind').closest('.item');
|
||||||
|
expect(spanKindItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers over the span kind field
|
||||||
|
await user.hover(spanKindItem!);
|
||||||
|
|
||||||
|
const filterOutButton = spanKindItem!.querySelector(
|
||||||
|
'[aria-label="Filter out value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(filterOutButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User clicks "Filter out value" button
|
||||||
|
await user.click(filterOutButton);
|
||||||
|
|
||||||
|
// Verify navigation to traces explorer with exclusion filter
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dataSource: 'traces',
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'spanKind' }),
|
||||||
|
op: '!=',
|
||||||
|
value: 'client',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Copy Actions Flow', () => {
|
||||||
|
it('should allow user to copy field name and field value through popover actions', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
statusCodeString: 'ERROR',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the status code string displayed
|
||||||
|
expect(screen.getByText('status code string')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ERROR')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the status code string field item
|
||||||
|
const statusCodeItem = screen
|
||||||
|
.getByText('status code string')
|
||||||
|
.closest('.item');
|
||||||
|
expect(statusCodeItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers over the field to reveal action buttons
|
||||||
|
await user.hover(statusCodeItem!);
|
||||||
|
|
||||||
|
// User clicks the more actions button (ellipsis)
|
||||||
|
const moreActionsButton = statusCodeItem!
|
||||||
|
.querySelector('.lucide-ellipsis')
|
||||||
|
?.closest('button') as HTMLElement;
|
||||||
|
expect(moreActionsButton).toBeInTheDocument();
|
||||||
|
await user.click(moreActionsButton);
|
||||||
|
|
||||||
|
// Verify popover opens with copy options
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// User clicks "Copy Field Name"
|
||||||
|
const copyFieldNameButton = screen.getByText('Copy Field Name');
|
||||||
|
fireEvent.click(copyFieldNameButton);
|
||||||
|
|
||||||
|
// Verify field name is copied with correct mapping
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCopy).toHaveBeenCalledWith('statusCodeString');
|
||||||
|
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||||
|
message: 'Field name copied to clipboard',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset mocks and test copy field value
|
||||||
|
mockSetCopy.mockClear();
|
||||||
|
mockNotifications.success.mockClear();
|
||||||
|
|
||||||
|
// Open popover again for copy field value test
|
||||||
|
await user.hover(statusCodeItem!);
|
||||||
|
await user.click(moreActionsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// User clicks "Copy Field Value"
|
||||||
|
const copyFieldValueButton = screen.getByText('Copy Field Value');
|
||||||
|
fireEvent.click(copyFieldValueButton);
|
||||||
|
|
||||||
|
// Verify field value is copied
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCopy).toHaveBeenCalledWith('ERROR');
|
||||||
|
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||||
|
message: 'Field value copied to clipboard',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Standard Fields', () => {
|
||||||
|
it('should work consistently across different field types with proper field mappings', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
spanId: 'abc123def456',
|
||||||
|
name: 'Database Query',
|
||||||
|
serviceName: 'db-service',
|
||||||
|
spanKind: 'internal',
|
||||||
|
statusCodeString: 'OK',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// Test span ID field with its mapping
|
||||||
|
const spanIdItem = screen.getByText('span id').closest('.item');
|
||||||
|
await user.hover(spanIdItem!);
|
||||||
|
const spanIdFilterButton = spanIdItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(spanIdFilterButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'span_id' }),
|
||||||
|
op: '=',
|
||||||
|
value: 'abc123def456',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRedirectWithQueryBuilderData.mockClear();
|
||||||
|
|
||||||
|
// Test span name field
|
||||||
|
const spanNameItem = screen.getByText('span name').closest('.item');
|
||||||
|
await user.hover(spanNameItem!);
|
||||||
|
const spanNameFilterButton = spanNameItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(spanNameFilterButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'name' }),
|
||||||
|
op: '=',
|
||||||
|
value: 'Database Query',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Special Field Values', () => {
|
||||||
|
it('should handle duration field with numeric values properly', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
durationNano: 250000000, // 250ms
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the duration displayed (formatted)
|
||||||
|
expect(screen.getByText('duration')).toBeInTheDocument();
|
||||||
|
// Duration should be formatted by getYAxisFormattedValue, but we test the raw value is used in filter
|
||||||
|
|
||||||
|
// Find the duration field item
|
||||||
|
const durationItem = screen.getByText('duration').closest('.item');
|
||||||
|
expect(durationItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers and clicks filter for
|
||||||
|
await user.hover(durationItem!);
|
||||||
|
const filterForButton = durationItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(filterForButton);
|
||||||
|
|
||||||
|
// Verify the raw numeric value is used in the filter, not the formatted display value
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dataSource: 'traces',
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'durationNano' }),
|
||||||
|
op: '=',
|
||||||
|
value: '250000000', // Raw numeric value as string
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fields with special characters and preserve exact field values', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
name: 'POST /api/users/create',
|
||||||
|
statusCodeString: '"INTERNAL_ERROR"', // Quoted value to test exact preservation
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// Test span name with special characters
|
||||||
|
const spanNameItem = screen.getByText('span name').closest('.item');
|
||||||
|
await user.hover(spanNameItem!);
|
||||||
|
const moreActionsButton = spanNameItem!
|
||||||
|
.querySelector('.lucide-ellipsis')
|
||||||
|
?.closest('button') as HTMLElement;
|
||||||
|
await user.click(moreActionsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyFieldValueButton = screen.getByText('Copy Field Value');
|
||||||
|
fireEvent.click(copyFieldValueButton);
|
||||||
|
|
||||||
|
// Verify special characters are handled correctly
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCopy).toHaveBeenCalledWith('POST /api/users/create');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset and test quoted value handling
|
||||||
|
mockSetCopy.mockClear();
|
||||||
|
|
||||||
|
// Test status code string with quotes (should preserve exact value)
|
||||||
|
const statusItem = screen.getByText('status code string').closest('.item');
|
||||||
|
await user.hover(statusItem!);
|
||||||
|
const statusMoreActionsButton = statusItem!
|
||||||
|
.querySelector('.lucide-ellipsis')
|
||||||
|
?.closest('button') as HTMLElement;
|
||||||
|
await user.click(statusMoreActionsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCopyButton = screen.getByText('Copy Field Value');
|
||||||
|
fireEvent.click(statusCopyButton);
|
||||||
|
|
||||||
|
// Verify exact field value is preserved (including quotes)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCopy).toHaveBeenCalledWith('"INTERNAL_ERROR"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle status message field with action buttons when present', async () => {
|
||||||
|
const testSpan = createMockSpan({
|
||||||
|
statusMessage: 'Connection timeout error',
|
||||||
|
});
|
||||||
|
const { user } = renderSpanDetailsDrawer(testSpan);
|
||||||
|
|
||||||
|
// User sees the status message displayed
|
||||||
|
expect(screen.getByText('status message')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Connection timeout error')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the status message field item
|
||||||
|
const statusMessageItem = screen
|
||||||
|
.getByText('status message')
|
||||||
|
.closest('.item');
|
||||||
|
expect(statusMessageItem).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers over the status message field to reveal action buttons
|
||||||
|
await user.hover(statusMessageItem!);
|
||||||
|
|
||||||
|
const filterForButton = statusMessageItem!.querySelector(
|
||||||
|
'[aria-label="Filter for value"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(filterForButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User clicks "Filter for value" button
|
||||||
|
await user.click(filterForButton);
|
||||||
|
|
||||||
|
// Verify navigation to traces explorer with correct filter mapping
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
builder: expect.objectContaining({
|
||||||
|
queryData: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dataSource: 'traces',
|
||||||
|
filters: expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: expect.objectContaining({ key: 'statusMessage' }),
|
||||||
|
op: '=',
|
||||||
|
value: 'Connection timeout error',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
ROUTES.TRACES_EXPLORER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset and test copy functionality
|
||||||
|
mockRedirectWithQueryBuilderData.mockClear();
|
||||||
|
|
||||||
|
// Test copy field name functionality
|
||||||
|
await user.hover(statusMessageItem!);
|
||||||
|
const moreActionsButton = statusMessageItem!
|
||||||
|
.querySelector('.lucide-ellipsis')
|
||||||
|
?.closest('button') as HTMLElement;
|
||||||
|
await user.click(moreActionsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyFieldNameButton = screen.getByText('Copy Field Name');
|
||||||
|
fireEvent.click(copyFieldNameButton);
|
||||||
|
|
||||||
|
// Verify field name mapping (display "status message" → query "statusMessage")
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCopy).toHaveBeenCalledWith('statusMessage');
|
||||||
|
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||||
|
message: 'Field name copied to clipboard',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import { cloneDeep, isEqual } from 'lodash-es';
|
import { cloneDeep, isEqual } from 'lodash-es';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||||
Location,
|
|
||||||
NavigateFunction,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
} from 'react-router-dom-v5-compat';
|
|
||||||
import { isEventObject } from 'utils/isEventObject';
|
|
||||||
|
|
||||||
// state uses 'any' because react-router's NavigateOptions interface uses it
|
|
||||||
interface NavigateOptions {
|
interface NavigateOptions {
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
state?: any;
|
state?: any;
|
||||||
@@ -90,74 +83,6 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
|||||||
|
|
||||||
return newKeys.length > 0;
|
return newKeys.length > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to extract options from arguments
|
|
||||||
const extractOptions = (
|
|
||||||
optionsOrEvent?:
|
|
||||||
| NavigateOptions
|
|
||||||
| React.MouseEvent
|
|
||||||
| MouseEvent
|
|
||||||
| KeyboardEvent,
|
|
||||||
options?: NavigateOptions,
|
|
||||||
): NavigateOptions => {
|
|
||||||
const isEvent = isEventObject(optionsOrEvent);
|
|
||||||
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
|
|
||||||
|
|
||||||
const shouldOpenInNewTab =
|
|
||||||
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actualOptions,
|
|
||||||
newTab: shouldOpenInNewTab || actualOptions?.newTab,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to create target URL
|
|
||||||
const createTargetUrl = (
|
|
||||||
to: string | SafeNavigateParams,
|
|
||||||
location: Location,
|
|
||||||
): URL => {
|
|
||||||
if (typeof to === 'string') {
|
|
||||||
return new URL(to, window.location.origin);
|
|
||||||
}
|
|
||||||
return new URL(
|
|
||||||
`${to.pathname || location.pathname}${to.search || ''}`,
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to handle new tab navigation
|
|
||||||
const handleNewTabNavigation = (
|
|
||||||
to: string | SafeNavigateParams,
|
|
||||||
location: Location,
|
|
||||||
): void => {
|
|
||||||
const targetPath =
|
|
||||||
typeof to === 'string'
|
|
||||||
? to
|
|
||||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
|
||||||
window.open(targetPath, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to perform navigation
|
|
||||||
const performNavigation = (
|
|
||||||
to: string | SafeNavigateParams,
|
|
||||||
navigationOptions: NavigateOptions,
|
|
||||||
navigate: NavigateFunction,
|
|
||||||
location: Location,
|
|
||||||
): void => {
|
|
||||||
if (typeof to === 'string') {
|
|
||||||
navigate(to, navigationOptions);
|
|
||||||
} else {
|
|
||||||
navigate(
|
|
||||||
{
|
|
||||||
pathname: to.pathname || location.pathname,
|
|
||||||
search: to.search,
|
|
||||||
},
|
|
||||||
navigationOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSafeNavigate = (
|
export const useSafeNavigate = (
|
||||||
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
||||||
preventSameUrlNavigation: true,
|
preventSameUrlNavigation: true,
|
||||||
@@ -165,11 +90,6 @@ export const useSafeNavigate = (
|
|||||||
): {
|
): {
|
||||||
safeNavigate: (
|
safeNavigate: (
|
||||||
to: string | SafeNavigateParams,
|
to: string | SafeNavigateParams,
|
||||||
optionsOrEvent?:
|
|
||||||
| NavigateOptions
|
|
||||||
| React.MouseEvent
|
|
||||||
| MouseEvent
|
|
||||||
| KeyboardEvent,
|
|
||||||
options?: NavigateOptions,
|
options?: NavigateOptions,
|
||||||
) => void;
|
) => void;
|
||||||
} => {
|
} => {
|
||||||
@@ -177,25 +97,30 @@ export const useSafeNavigate = (
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const safeNavigate = useCallback(
|
const safeNavigate = useCallback(
|
||||||
(
|
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
|
||||||
to: string | SafeNavigateParams,
|
|
||||||
optionsOrEvent?:
|
|
||||||
| NavigateOptions
|
|
||||||
| React.MouseEvent
|
|
||||||
| MouseEvent
|
|
||||||
| KeyboardEvent,
|
|
||||||
options?: NavigateOptions,
|
|
||||||
) => {
|
|
||||||
const finalOptions = extractOptions(optionsOrEvent, options);
|
|
||||||
const currentUrl = new URL(
|
const currentUrl = new URL(
|
||||||
`${location.pathname}${location.search}`,
|
`${location.pathname}${location.search}`,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const targetUrl = createTargetUrl(to, location);
|
|
||||||
|
|
||||||
// Handle new tab navigation
|
let targetUrl: URL;
|
||||||
if (finalOptions?.newTab) {
|
|
||||||
handleNewTabNavigation(to, location);
|
if (typeof to === 'string') {
|
||||||
|
targetUrl = new URL(to, window.location.origin);
|
||||||
|
} else {
|
||||||
|
targetUrl = new URL(
|
||||||
|
`${to.pathname || location.pathname}${to.search || ''}`,
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If newTab is true, open in new tab and return early
|
||||||
|
if (options?.newTab) {
|
||||||
|
const targetPath =
|
||||||
|
typeof to === 'string'
|
||||||
|
? to
|
||||||
|
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||||
|
window.open(targetPath, '_blank');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,13 +132,23 @@ export const useSafeNavigate = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navigationOptions = {
|
const navigationOptions = {
|
||||||
...finalOptions,
|
...options,
|
||||||
replace: isDefaultParamsNavigation || finalOptions?.replace,
|
replace: isDefaultParamsNavigation || options?.replace,
|
||||||
};
|
};
|
||||||
|
|
||||||
performNavigation(to, navigationOptions, navigate, location);
|
if (typeof to === 'string') {
|
||||||
|
navigate(to, navigationOptions);
|
||||||
|
} else {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: to.pathname || location.pathname,
|
||||||
|
search: to.search,
|
||||||
|
},
|
||||||
|
navigationOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[navigate, location, preventSameUrlNavigation],
|
[navigate, location.pathname, location.search, preventSameUrlNavigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { safeNavigate };
|
return { safeNavigate };
|
||||||
|
|||||||
@@ -1,634 +0,0 @@
|
|||||||
/* eslint-disable sonarjs/no-duplicate-string */
|
|
||||||
import { LocationDescriptorObject } from 'history';
|
|
||||||
|
|
||||||
import history from '../history';
|
|
||||||
|
|
||||||
jest.mock('history', () => {
|
|
||||||
const actualHistory = jest.requireActual('history');
|
|
||||||
const mockPush = jest.fn();
|
|
||||||
const mockReplace = jest.fn();
|
|
||||||
const mockGo = jest.fn();
|
|
||||||
const mockGoBack = jest.fn();
|
|
||||||
const mockGoForward = jest.fn();
|
|
||||||
const mockBlock = jest.fn(() => jest.fn());
|
|
||||||
const mockListen = jest.fn(() => jest.fn());
|
|
||||||
const mockCreateHref = jest.fn((location) => {
|
|
||||||
if (typeof location === 'string') return location;
|
|
||||||
return actualHistory.createPath(location);
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseHistory = {
|
|
||||||
length: 2,
|
|
||||||
action: 'PUSH' as const,
|
|
||||||
location: {
|
|
||||||
pathname: '/current-path',
|
|
||||||
search: '?existing=param',
|
|
||||||
hash: '#section',
|
|
||||||
state: { existing: 'state' },
|
|
||||||
key: 'test-key',
|
|
||||||
},
|
|
||||||
push: mockPush,
|
|
||||||
replace: mockReplace,
|
|
||||||
go: mockGo,
|
|
||||||
goBack: mockGoBack,
|
|
||||||
goForward: mockGoForward,
|
|
||||||
block: mockBlock,
|
|
||||||
listen: mockListen,
|
|
||||||
createHref: mockCreateHref,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actualHistory,
|
|
||||||
createBrowserHistory: jest.fn(() => baseHistory),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface TestUser {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestState {
|
|
||||||
from?: string;
|
|
||||||
user?: TestUser;
|
|
||||||
timestamp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Enhanced History Methods', () => {
|
|
||||||
let mockWindowOpen: jest.SpyInstance;
|
|
||||||
let originalPush: jest.MockedFunction<typeof history.push>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
|
|
||||||
|
|
||||||
originalPush = history.originalPush as jest.MockedFunction<
|
|
||||||
typeof history.push
|
|
||||||
>;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockWindowOpen.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history.push() - String Path Navigation', () => {
|
|
||||||
it('should handle simple string path navigation', () => {
|
|
||||||
history.push('/dashboard');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledTimes(1);
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle string path with state', () => {
|
|
||||||
const testState: TestState = { from: 'home', timestamp: Date.now() };
|
|
||||||
|
|
||||||
history.push('/dashboard', testState);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', testState);
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle string path with query parameters', () => {
|
|
||||||
history.push('/logs?filter=error&timeRange=24h');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(
|
|
||||||
'/logs?filter=error&timeRange=24h',
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle string path with hash', () => {
|
|
||||||
history.push('/docs#installation');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/docs#installation', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex URL with all components', () => {
|
|
||||||
const complexUrl = '/api/traces?service=backend&status=error#span-details';
|
|
||||||
const state: TestState = {
|
|
||||||
user: { id: 1, name: 'John', email: 'john@test.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(complexUrl, state);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(complexUrl, state);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history.push() - Location Object Navigation', () => {
|
|
||||||
it('should handle location object with only pathname', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/metrics',
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle location object with pathname and search', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/logs',
|
|
||||||
search: '?filter=error&severity=high',
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle location object with all properties', () => {
|
|
||||||
const location: LocationDescriptorObject<TestState> = {
|
|
||||||
pathname: '/traces',
|
|
||||||
search: '?service=api-server&duration=slow',
|
|
||||||
hash: '#span-123',
|
|
||||||
state: { from: 'dashboard', timestamp: Date.now() },
|
|
||||||
key: 'unique-key',
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle location object with state passed separately', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/alerts',
|
|
||||||
search: '?type=critical',
|
|
||||||
};
|
|
||||||
const separateState: TestState = { from: 'monitoring' };
|
|
||||||
|
|
||||||
history.push(location, separateState);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, separateState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty location object', () => {
|
|
||||||
const location: LocationDescriptorObject = {};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve current pathname when updating search', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: history.location.pathname,
|
|
||||||
search: '?newParam=value',
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
expect(originalPush.mock.calls[0][0]).toHaveProperty(
|
|
||||||
'pathname',
|
|
||||||
'/current-path',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history.push() - Event Handling (Cmd/Ctrl+Click)', () => {
|
|
||||||
describe('MouseEvent handling', () => {
|
|
||||||
it('should open in new tab when metaKey is pressed with string path', () => {
|
|
||||||
const event = new MouseEvent('click', { metaKey: true });
|
|
||||||
|
|
||||||
history.push('/dashboard', event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open in new tab when ctrlKey is pressed with string path', () => {
|
|
||||||
const event = new MouseEvent('click', { ctrlKey: true });
|
|
||||||
|
|
||||||
history.push('/metrics', event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/metrics', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open in new tab when both metaKey and ctrlKey are pressed', () => {
|
|
||||||
const event = new MouseEvent('click', { metaKey: true, ctrlKey: true });
|
|
||||||
|
|
||||||
history.push('/logs', event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle normal click without meta/ctrl keys', () => {
|
|
||||||
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
|
|
||||||
const state: TestState = { from: 'nav' };
|
|
||||||
|
|
||||||
history.push('/alerts', event, state);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/alerts', state);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('KeyboardEvent handling', () => {
|
|
||||||
it('should open in new tab when metaKey is pressed with keyboard event', () => {
|
|
||||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
|
||||||
|
|
||||||
history.push('/traces', event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/traces', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open in new tab when ctrlKey is pressed with keyboard event', () => {
|
|
||||||
const event = new KeyboardEvent('keydown', { ctrlKey: true });
|
|
||||||
|
|
||||||
history.push('/pipelines', event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/pipelines', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('React SyntheticEvent handling', () => {
|
|
||||||
it('should handle React MouseEvent with metaKey', () => {
|
|
||||||
const nativeEvent = new MouseEvent('click', { metaKey: true });
|
|
||||||
const reactEvent = {
|
|
||||||
nativeEvent,
|
|
||||||
metaKey: true,
|
|
||||||
ctrlKey: false,
|
|
||||||
} as React.MouseEvent;
|
|
||||||
|
|
||||||
history.push('/dashboard', reactEvent);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle React MouseEvent with ctrlKey', () => {
|
|
||||||
const nativeEvent = new MouseEvent('click', { ctrlKey: true });
|
|
||||||
const reactEvent = {
|
|
||||||
nativeEvent,
|
|
||||||
metaKey: false,
|
|
||||||
ctrlKey: true,
|
|
||||||
} as React.MouseEvent;
|
|
||||||
|
|
||||||
history.push('/logs', reactEvent);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle React MouseEvent without modifier keys', () => {
|
|
||||||
const nativeEvent = new MouseEvent('click');
|
|
||||||
const reactEvent = {
|
|
||||||
nativeEvent,
|
|
||||||
metaKey: false,
|
|
||||||
ctrlKey: false,
|
|
||||||
} as React.MouseEvent;
|
|
||||||
|
|
||||||
history.push('/metrics', reactEvent);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/metrics', undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Location Object with Event handling', () => {
|
|
||||||
it('should open location object URL in new tab with metaKey', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/traces',
|
|
||||||
search: '?service=backend',
|
|
||||||
hash: '#span-details',
|
|
||||||
};
|
|
||||||
const event = new MouseEvent('click', { metaKey: true });
|
|
||||||
|
|
||||||
history.push(location, event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
|
||||||
'/traces?service=backend#span-details',
|
|
||||||
'_blank',
|
|
||||||
);
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open location object URL in new tab with ctrlKey', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/alerts',
|
|
||||||
search: '?status=firing',
|
|
||||||
};
|
|
||||||
const event = new MouseEvent('click', { ctrlKey: true });
|
|
||||||
|
|
||||||
history.push(location, event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
|
||||||
'/alerts?status=firing',
|
|
||||||
'_blank',
|
|
||||||
);
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle location object with normal navigation', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/dashboard',
|
|
||||||
search: '?tab=overview',
|
|
||||||
};
|
|
||||||
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
|
|
||||||
const state: TestState = { from: 'home' };
|
|
||||||
|
|
||||||
history.push(location, event, state);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex location object with all properties in new tab', () => {
|
|
||||||
const location: LocationDescriptorObject<TestState> = {
|
|
||||||
pathname: '/api/v1/traces',
|
|
||||||
search: '?limit=100&offset=0&service=auth',
|
|
||||||
hash: '#result-section',
|
|
||||||
state: { from: 'explorer' }, // State is ignored in new tab
|
|
||||||
};
|
|
||||||
const event = new MouseEvent('click', { metaKey: true });
|
|
||||||
|
|
||||||
history.push(location, event);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/traces?limit=100&offset=0&service=auth#result-section',
|
|
||||||
'_blank',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history.push() - Edge Cases and Error Scenarios', () => {
|
|
||||||
it('should handle undefined as second parameter', () => {
|
|
||||||
history.push('/dashboard', undefined);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null as second parameter', () => {
|
|
||||||
history.push('/logs', null);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/logs', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty string path', () => {
|
|
||||||
history.push('');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle root path', () => {
|
|
||||||
history.push('/');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle relative paths', () => {
|
|
||||||
history.push('../parent');
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('../parent', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in path', () => {
|
|
||||||
const specialPath = '/path/with spaces/and#special?chars=@$%';
|
|
||||||
|
|
||||||
history.push(specialPath);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(specialPath, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle location object with undefined values', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: undefined,
|
|
||||||
search: undefined,
|
|
||||||
hash: undefined,
|
|
||||||
state: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push(location);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very long URLs', () => {
|
|
||||||
const longParam = 'x'.repeat(1000);
|
|
||||||
const longUrl = `/path?param=${longParam}`;
|
|
||||||
|
|
||||||
history.push(longUrl);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(longUrl, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle object that looks like an event but isnt', () => {
|
|
||||||
const fakeEvent = {
|
|
||||||
metaKey: 'not-a-boolean', // Invalid type but still truthy values
|
|
||||||
ctrlKey: 'not-a-boolean',
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push('/dashboard', fakeEvent as any);
|
|
||||||
|
|
||||||
// The implementation checks if metaKey/ctrlKey exist and are truthy values
|
|
||||||
// Since these are truthy strings, it will be treated as an event
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle event-like object with falsy values', () => {
|
|
||||||
const fakeEventFalsy = {
|
|
||||||
metaKey: false,
|
|
||||||
ctrlKey: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push('/dashboard', fakeEventFalsy as any);
|
|
||||||
|
|
||||||
// The object is detected as an event (has metaKey/ctrlKey properties)
|
|
||||||
// but since both are false, it doesn't open in new tab
|
|
||||||
// When treated as event, third param (state) is undefined
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle partial event-like objects', () => {
|
|
||||||
const partialEvent = { metaKey: true }; // Has metaKey but not instanceof MouseEvent
|
|
||||||
|
|
||||||
history.push('/logs', partialEvent as any);
|
|
||||||
|
|
||||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
|
||||||
expect(originalPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle object without event properties as state', () => {
|
|
||||||
const regularObject = {
|
|
||||||
someData: 'value',
|
|
||||||
anotherProp: 123,
|
|
||||||
// No metaKey or ctrlKey properties
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push('/page', regularObject);
|
|
||||||
|
|
||||||
// Object without metaKey/ctrlKey is treated as state, not event
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/page', regularObject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history.push() - State Handling', () => {
|
|
||||||
it('should pass state with string path', () => {
|
|
||||||
const complexState: TestState = {
|
|
||||||
from: 'dashboard',
|
|
||||||
user: { id: 123, name: 'Test User', email: 'test@example.com' },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
history.push('/profile', complexState);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/profile', complexState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle state with location object', () => {
|
|
||||||
const location: LocationDescriptorObject<TestState> = {
|
|
||||||
pathname: '/settings',
|
|
||||||
state: { from: 'profile' },
|
|
||||||
};
|
|
||||||
const additionalState: TestState = { timestamp: Date.now() };
|
|
||||||
|
|
||||||
history.push(location, additionalState);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, additionalState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle state with event and string path', () => {
|
|
||||||
const event = new MouseEvent('click', { metaKey: false });
|
|
||||||
const state: TestState = { from: 'nav' };
|
|
||||||
|
|
||||||
history.push('/dashboard', event, state);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle state with event and location object', () => {
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/logs',
|
|
||||||
};
|
|
||||||
const event = new MouseEvent('click', { metaKey: false });
|
|
||||||
const state: TestState = { from: 'sidebar' };
|
|
||||||
|
|
||||||
history.push(location, event, state);
|
|
||||||
|
|
||||||
expect(originalPush).toHaveBeenCalledWith(location, state);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Other History Methods', () => {
|
|
||||||
it('should have working replace method', () => {
|
|
||||||
// replace should exist and be callable
|
|
||||||
expect(history.replace).toBeDefined();
|
|
||||||
expect(typeof history.replace).toBe('function');
|
|
||||||
|
|
||||||
history.replace('/new-path');
|
|
||||||
|
|
||||||
const mockReplace = (history as any).replace as jest.MockedFunction<
|
|
||||||
typeof history.replace
|
|
||||||
>;
|
|
||||||
expect(mockReplace).toHaveBeenCalledWith('/new-path');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working go method', () => {
|
|
||||||
expect(history.go).toBeDefined();
|
|
||||||
expect(typeof history.go).toBe('function');
|
|
||||||
|
|
||||||
history.go(-2);
|
|
||||||
|
|
||||||
const mockGo = (history as any).go as jest.MockedFunction<typeof history.go>;
|
|
||||||
expect(mockGo).toHaveBeenCalledWith(-2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working goBack method', () => {
|
|
||||||
expect(history.goBack).toBeDefined();
|
|
||||||
expect(typeof history.goBack).toBe('function');
|
|
||||||
|
|
||||||
history.goBack();
|
|
||||||
|
|
||||||
const mockGoBack = (history as any).goBack as jest.MockedFunction<
|
|
||||||
typeof history.goBack
|
|
||||||
>;
|
|
||||||
expect(mockGoBack).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working goForward method', () => {
|
|
||||||
expect(history.goForward).toBeDefined();
|
|
||||||
expect(typeof history.goForward).toBe('function');
|
|
||||||
|
|
||||||
history.goForward();
|
|
||||||
|
|
||||||
const mockGoForward = (history as any).goForward as jest.MockedFunction<
|
|
||||||
typeof history.goForward
|
|
||||||
>;
|
|
||||||
expect(mockGoForward).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working block method', () => {
|
|
||||||
expect(history.block).toBeDefined();
|
|
||||||
expect(typeof history.block).toBe('function');
|
|
||||||
|
|
||||||
const unblock = history.block('Are you sure?');
|
|
||||||
|
|
||||||
expect(typeof unblock).toBe('function');
|
|
||||||
const mockBlock = (history as any).block as jest.MockedFunction<
|
|
||||||
typeof history.block
|
|
||||||
>;
|
|
||||||
expect(mockBlock).toHaveBeenCalledWith('Are you sure?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working listen method', () => {
|
|
||||||
expect(history.listen).toBeDefined();
|
|
||||||
expect(typeof history.listen).toBe('function');
|
|
||||||
|
|
||||||
const listener = jest.fn();
|
|
||||||
|
|
||||||
const unlisten = history.listen(listener);
|
|
||||||
|
|
||||||
expect(typeof unlisten).toBe('function');
|
|
||||||
const mockListen = (history as any).listen as jest.MockedFunction<
|
|
||||||
typeof history.listen
|
|
||||||
>;
|
|
||||||
expect(mockListen).toHaveBeenCalledWith(listener);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have working createHref method', () => {
|
|
||||||
expect(history.createHref).toBeDefined();
|
|
||||||
expect(typeof history.createHref).toBe('function');
|
|
||||||
|
|
||||||
const location: LocationDescriptorObject = {
|
|
||||||
pathname: '/test',
|
|
||||||
search: '?query=value',
|
|
||||||
};
|
|
||||||
|
|
||||||
const href = history.createHref(location);
|
|
||||||
|
|
||||||
expect(href).toBe('/test?query=value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have accessible location property', () => {
|
|
||||||
expect(history.location).toBeDefined();
|
|
||||||
expect(history.location.pathname).toBe('/current-path');
|
|
||||||
expect(history.location.search).toBe('?existing=param');
|
|
||||||
expect(history.location.hash).toBe('#section');
|
|
||||||
expect(history.location.state).toEqual({ existing: 'state' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have accessible length property', () => {
|
|
||||||
expect(history.length).toBeDefined();
|
|
||||||
expect(history.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have accessible action property', () => {
|
|
||||||
expect(history.action).toBeDefined();
|
|
||||||
expect(history.action).toBe('PUSH');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +1,3 @@
|
|||||||
import {
|
import { createBrowserHistory } from 'history';
|
||||||
createBrowserHistory,
|
|
||||||
createPath,
|
|
||||||
History,
|
|
||||||
LocationDescriptorObject,
|
|
||||||
LocationState,
|
|
||||||
} from 'history';
|
|
||||||
import { isEventObject } from 'utils/isEventObject';
|
|
||||||
|
|
||||||
// Create the base history instance
|
export default createBrowserHistory();
|
||||||
const baseHistory = createBrowserHistory();
|
|
||||||
|
|
||||||
type PathOrLocation = string | LocationDescriptorObject<LocationState>;
|
|
||||||
|
|
||||||
// Extend the History interface to include enhanced push method
|
|
||||||
interface EnhancedHistory extends History {
|
|
||||||
push: {
|
|
||||||
(path: PathOrLocation, state?: any): void;
|
|
||||||
(
|
|
||||||
path: PathOrLocation,
|
|
||||||
event?: React.MouseEvent | MouseEvent | KeyboardEvent,
|
|
||||||
state?: any,
|
|
||||||
): void;
|
|
||||||
};
|
|
||||||
originalPush: History['push'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create enhanced history with overridden push method
|
|
||||||
const history = baseHistory as EnhancedHistory;
|
|
||||||
|
|
||||||
// Store the original push method
|
|
||||||
history.originalPush = baseHistory.push;
|
|
||||||
|
|
||||||
// Override push to handle meta/ctrl key events and location objects
|
|
||||||
history.push = function (
|
|
||||||
path: PathOrLocation,
|
|
||||||
eventOrState?: React.MouseEvent | MouseEvent | KeyboardEvent | any,
|
|
||||||
state?: any,
|
|
||||||
): void {
|
|
||||||
// Check if second argument is an event object
|
|
||||||
const isEvent = isEventObject(eventOrState);
|
|
||||||
|
|
||||||
// If it's an event and meta/ctrl key is pressed, open in new tab
|
|
||||||
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
|
|
||||||
// Convert location object to URL string using createPath from history
|
|
||||||
const url = typeof path === 'string' ? path : createPath(path);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, use normal navigation
|
|
||||||
// The original push method already handles both strings and location objects
|
|
||||||
// If eventOrState is not an event, treat it as state
|
|
||||||
const actualState = isEvent ? state : eventOrState;
|
|
||||||
history.originalPush(path, actualState);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default history;
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { getYAxisScale } from './utils/getYAxisScale';
|
|||||||
interface ExtendedUPlot extends uPlot {
|
interface ExtendedUPlot extends uPlot {
|
||||||
_legendScrollCleanup?: () => void;
|
_legendScrollCleanup?: () => void;
|
||||||
_tooltipCleanup?: () => void;
|
_tooltipCleanup?: () => void;
|
||||||
|
_legendElementCleanup?: Array<() => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUPlotChartOptions {
|
export interface GetUPlotChartOptions {
|
||||||
@@ -473,6 +474,9 @@ export const getUPlotChartOptions = ({
|
|||||||
if (legend) {
|
if (legend) {
|
||||||
const legendElement = legend as HTMLElement;
|
const legendElement = legend as HTMLElement;
|
||||||
|
|
||||||
|
// Initialize cleanup array for legend element listeners
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup = [];
|
||||||
|
|
||||||
// Apply enhanced legend styling
|
// Apply enhanced legend styling
|
||||||
if (enhancedLegend) {
|
if (enhancedLegend) {
|
||||||
applyEnhancedLegendStyling(
|
applyEnhancedLegendStyling(
|
||||||
@@ -639,6 +643,17 @@ export const getUPlotChartOptions = ({
|
|||||||
thElement.addEventListener('mouseenter', showTooltip);
|
thElement.addEventListener('mouseenter', showTooltip);
|
||||||
thElement.addEventListener('mouseleave', hideTooltip);
|
thElement.addEventListener('mouseleave', hideTooltip);
|
||||||
|
|
||||||
|
// Store cleanup function for tooltip listeners
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
|
||||||
|
thElement.removeEventListener('mouseenter', showTooltip);
|
||||||
|
thElement.removeEventListener('mouseleave', hideTooltip);
|
||||||
|
// Cleanup any lingering tooltip
|
||||||
|
if (tooltipElement) {
|
||||||
|
tooltipElement.remove();
|
||||||
|
tooltipElement = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add click handlers for marker and text separately
|
// Add click handlers for marker and text separately
|
||||||
const currentMarker = thElement.querySelector('.u-marker');
|
const currentMarker = thElement.querySelector('.u-marker');
|
||||||
const textElement = thElement.querySelector('.legend-text');
|
const textElement = thElement.querySelector('.legend-text');
|
||||||
@@ -658,7 +673,7 @@ export const getUPlotChartOptions = ({
|
|||||||
|
|
||||||
// Marker click handler - checkbox behavior (toggle individual series)
|
// Marker click handler - checkbox behavior (toggle individual series)
|
||||||
if (currentMarker) {
|
if (currentMarker) {
|
||||||
currentMarker.addEventListener('click', (e) => {
|
const markerClickHandler = (e: Event): void => {
|
||||||
e.stopPropagation?.(); // Prevent event bubbling to text handler
|
e.stopPropagation?.(); // Prevent event bubbling to text handler
|
||||||
|
|
||||||
if (stackChart) {
|
if (stackChart) {
|
||||||
@@ -680,12 +695,19 @@ export const getUPlotChartOptions = ({
|
|||||||
return newGraphVisibilityStates;
|
return newGraphVisibilityStates;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentMarker.addEventListener('click', markerClickHandler);
|
||||||
|
|
||||||
|
// Store cleanup function for marker click listener
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
|
||||||
|
currentMarker.removeEventListener('click', markerClickHandler);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text click handler - show only/show all behavior (existing behavior)
|
// Text click handler - show only/show all behavior (existing behavior)
|
||||||
if (textElement) {
|
if (textElement) {
|
||||||
textElement.addEventListener('click', (e) => {
|
const textClickHandler = (e: Event): void => {
|
||||||
e.stopPropagation?.(); // Prevent event bubbling
|
e.stopPropagation?.(); // Prevent event bubbling
|
||||||
|
|
||||||
if (stackChart) {
|
if (stackChart) {
|
||||||
@@ -716,6 +738,13 @@ export const getUPlotChartOptions = ({
|
|||||||
return newGraphVisibilityStates;
|
return newGraphVisibilityStates;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
textElement.addEventListener('click', textClickHandler);
|
||||||
|
|
||||||
|
// Store cleanup function for text click listener
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup?.push(() => {
|
||||||
|
textElement.removeEventListener('click', textClickHandler);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +752,33 @@ export const getUPlotChartOptions = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
destroy: [
|
||||||
|
(self): void => {
|
||||||
|
// Clean up legend scroll listener
|
||||||
|
if ((self as ExtendedUPlot)._legendScrollCleanup) {
|
||||||
|
(self as ExtendedUPlot)._legendScrollCleanup?.();
|
||||||
|
(self as ExtendedUPlot)._legendScrollCleanup = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tooltip global listener
|
||||||
|
if ((self as ExtendedUPlot)._tooltipCleanup) {
|
||||||
|
(self as ExtendedUPlot)._tooltipCleanup?.();
|
||||||
|
(self as ExtendedUPlot)._tooltipCleanup = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up all legend element listeners
|
||||||
|
if ((self as ExtendedUPlot)._legendElementCleanup) {
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup?.forEach((cleanup) => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
(self as ExtendedUPlot)._legendElementCleanup = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any remaining tooltips in DOM
|
||||||
|
const existingTooltips = document.querySelectorAll('.legend-tooltip');
|
||||||
|
existingTooltips.forEach((tooltip) => tooltip.remove());
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
series: customSeries
|
series: customSeries
|
||||||
? customSeries(apiResponse?.data?.result || [])
|
? customSeries(apiResponse?.data?.result || [])
|
||||||
|
|||||||
@@ -700,24 +700,23 @@ describe('TracesExplorer - ', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('select a view options - assert and save this view', async () => {
|
it('select a view options - assert and save this view', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
|
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
|
||||||
'/traces-explorer/?panelType=list&selectedExplorerView=list',
|
'/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||||
]);
|
]);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.mouseDown(
|
|
||||||
container.querySelector(
|
|
||||||
'.view-options .ant-select-selection-search-input',
|
|
||||||
) as HTMLElement,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewListOptions = await screen.findByRole('listbox');
|
const viewSearchInput = container.querySelector(
|
||||||
expect(viewListOptions).toBeInTheDocument();
|
'.view-options .ant-select-selection-search-input',
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
expect(within(viewListOptions).getByText('R-test panel')).toBeInTheDocument();
|
expect(viewSearchInput).toBeInTheDocument();
|
||||||
|
|
||||||
expect(within(viewListOptions).getByText('Table View')).toBeInTheDocument();
|
fireEvent.mouseDown(viewSearchInput);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('option', { name: 'R-test panel' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// save this view
|
// save this view
|
||||||
fireEvent.click(screen.getByText('Save this view'));
|
fireEvent.click(screen.getByText('Save this view'));
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
// Event types that have metaKey/ctrlKey properties
|
|
||||||
type EventWithModifiers =
|
|
||||||
| MouseEvent
|
|
||||||
| KeyboardEvent
|
|
||||||
| React.MouseEvent<any, MouseEvent>
|
|
||||||
| React.KeyboardEvent<any>;
|
|
||||||
|
|
||||||
// Helper function to determine if an argument is an event - Also used in utils/history.ts
|
|
||||||
export const isEventObject = (arg: unknown): arg is EventWithModifiers => {
|
|
||||||
if (!arg || typeof arg !== 'object') return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
arg instanceof MouseEvent ||
|
|
||||||
arg instanceof KeyboardEvent ||
|
|
||||||
('nativeEvent' in arg &&
|
|
||||||
(arg.nativeEvent instanceof MouseEvent ||
|
|
||||||
arg.nativeEvent instanceof KeyboardEvent)) ||
|
|
||||||
'metaKey' in arg ||
|
|
||||||
'ctrlKey' in arg
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user