mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-14 18:56:02 +08:00
chore: add in-table-search for metric name and type in metrics explorer (#7356)
This commit is contained in:
parent
a64908e571
commit
097e4ca948
@ -2,7 +2,6 @@ import axios from 'api';
|
|||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
|
||||||
|
|
||||||
export interface MetricsListFilterValuesPayload {
|
export interface MetricsListFilterValuesPayload {
|
||||||
filterAttributeKeyDataType: string;
|
filterAttributeKeyDataType: string;
|
||||||
@ -14,7 +13,7 @@ export interface MetricsListFilterValuesPayload {
|
|||||||
export interface MetricsListFilterValuesResponse {
|
export interface MetricsListFilterValuesResponse {
|
||||||
status: string;
|
status: string;
|
||||||
data: {
|
data: {
|
||||||
FilterValues: BaseAutocompleteData[];
|
filterValues: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
InputRef,
|
||||||
|
Menu,
|
||||||
|
MenuRef,
|
||||||
|
Popover,
|
||||||
|
Spin,
|
||||||
|
} from 'antd';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||||
|
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
|
||||||
|
function MetricNameSearch(): JSX.Element {
|
||||||
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
const { handleChangeQueryData } = useQueryOperations({
|
||||||
|
index: 0,
|
||||||
|
query: currentQuery.builder.queryData[0],
|
||||||
|
entityVersion: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
|
const [searchString, setSearchString] = useState<string>('');
|
||||||
|
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
|
const menuRef = useRef<MenuRef | null>(null);
|
||||||
|
const inputRef = useRef<InputRef | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPopoverOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0); // Ensures focus happens after popover opens
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metricNameFilterValuesData,
|
||||||
|
isLoading: isLoadingMetricNameFilterValues,
|
||||||
|
isError: isErrorMetricNameFilterValues,
|
||||||
|
} = useGetMetricsListFilterValues(
|
||||||
|
{
|
||||||
|
searchText: debouncedSearchString,
|
||||||
|
filterKey: 'metric_name',
|
||||||
|
filterAttributeKeyDataType: DataTypes.String,
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isPopoverOpen,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
queryKey: [
|
||||||
|
REACT_QUERY_KEY.GET_METRICS_LIST_FILTER_VALUES,
|
||||||
|
'metric_name',
|
||||||
|
debouncedSearchString,
|
||||||
|
isPopoverOpen,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(selectedMetricName: string): void => {
|
||||||
|
handleChangeQueryData('filters', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'metric_name',
|
||||||
|
op: 'CONTAINS',
|
||||||
|
key: {
|
||||||
|
id: 'metric_name',
|
||||||
|
key: 'metric_name',
|
||||||
|
type: 'tag',
|
||||||
|
},
|
||||||
|
value: selectedMetricName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
});
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
},
|
||||||
|
[handleChangeQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const metricNameFilterValues = useMemo(
|
||||||
|
() => metricNameFilterValuesData?.payload?.data?.filterValues || [],
|
||||||
|
[metricNameFilterValuesData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!isPopoverOpen) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => {
|
||||||
|
const nextIndex = prev < metricNameFilterValues.length - 1 ? prev + 1 : 0;
|
||||||
|
menuRef.current?.focus();
|
||||||
|
return nextIndex;
|
||||||
|
});
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => {
|
||||||
|
const prevIndex = prev > 0 ? prev - 1 : metricNameFilterValues.length - 1;
|
||||||
|
menuRef.current?.focus();
|
||||||
|
return prevIndex;
|
||||||
|
});
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
// If there is a highlighted item, select it
|
||||||
|
if (highlightedIndex >= 0 && metricNameFilterValues[highlightedIndex]) {
|
||||||
|
handleSelect(metricNameFilterValues[highlightedIndex]);
|
||||||
|
} else if (highlightedIndex === -1 && searchString) {
|
||||||
|
// If there is no highlighted item and there is a search string, select the search string
|
||||||
|
handleSelect(searchString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPopoverOpen,
|
||||||
|
highlightedIndex,
|
||||||
|
metricNameFilterValues,
|
||||||
|
searchString,
|
||||||
|
handleSelect,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const popoverItems = useMemo(() => {
|
||||||
|
const items: JSX.Element[] = [];
|
||||||
|
if (searchString) {
|
||||||
|
items.push(
|
||||||
|
<Menu.Item
|
||||||
|
key={searchString}
|
||||||
|
onClick={(): void => handleSelect(searchString)}
|
||||||
|
className={highlightedIndex === 0 ? 'highlighted' : ''}
|
||||||
|
>
|
||||||
|
{searchString}
|
||||||
|
</Menu.Item>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoadingMetricNameFilterValues) {
|
||||||
|
items.push(<Spin />);
|
||||||
|
} else if (isErrorMetricNameFilterValues) {
|
||||||
|
items.push(<Empty description="Error fetching metric names" />);
|
||||||
|
} else if (metricNameFilterValues?.length === 0) {
|
||||||
|
items.push(<Empty description="No metric names found" />);
|
||||||
|
} else {
|
||||||
|
items.push(
|
||||||
|
...metricNameFilterValues.map((filterValue, index) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={filterValue}
|
||||||
|
onClick={(): void => handleSelect(filterValue)}
|
||||||
|
className={highlightedIndex === index ? 'highlighted' : ''}
|
||||||
|
>
|
||||||
|
{filterValue}
|
||||||
|
</Menu.Item>
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [
|
||||||
|
handleSelect,
|
||||||
|
highlightedIndex,
|
||||||
|
isErrorMetricNameFilterValues,
|
||||||
|
isLoadingMetricNameFilterValues,
|
||||||
|
metricNameFilterValues,
|
||||||
|
searchString,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const debouncedUpdate = useDebouncedFn((value) => {
|
||||||
|
setDebouncedSearchString(value as string);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const value = e.target.value.trim().toLowerCase();
|
||||||
|
setSearchString(value);
|
||||||
|
debouncedUpdate(value);
|
||||||
|
},
|
||||||
|
[debouncedUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const popoverContent = useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="metric-name-search-popover">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchString}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
<Menu ref={menuRef} className="metric-name-search-popover-menu">
|
||||||
|
{popoverItems}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[handleKeyDown, searchString, handleInputChange, popoverItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setSearchString('');
|
||||||
|
setDebouncedSearchString('');
|
||||||
|
}
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={popoverContent}
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={(val): void => setIsPopoverOpen(val)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
icon={<Search size={14} />}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetricNameSearch;
|
@ -0,0 +1,92 @@
|
|||||||
|
import { Button, Menu, Popover, Tooltip } from 'antd';
|
||||||
|
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VALUES_MAP } from './constants';
|
||||||
|
|
||||||
|
function MetricTypeSearch(): JSX.Element {
|
||||||
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
const { handleChangeQueryData } = useQueryOperations({
|
||||||
|
index: 0,
|
||||||
|
query: currentQuery.builder.queryData[0],
|
||||||
|
entityVersion: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const menuItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
value: 'All',
|
||||||
|
},
|
||||||
|
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
|
||||||
|
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
|
||||||
|
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(selectedMetricType: string): void => {
|
||||||
|
if (selectedMetricType !== 'all') {
|
||||||
|
handleChangeQueryData('filters', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'metric_type',
|
||||||
|
op: '=',
|
||||||
|
key: {
|
||||||
|
id: 'metric_type',
|
||||||
|
key: 'metric_type',
|
||||||
|
type: 'tag',
|
||||||
|
},
|
||||||
|
value: selectedMetricType,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handleChangeQueryData('filters', {
|
||||||
|
items: currentQuery.builder.queryData[0].filters.items.filter(
|
||||||
|
(item) => item.id !== 'metric_type',
|
||||||
|
),
|
||||||
|
op: 'AND',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
},
|
||||||
|
[currentQuery.builder.queryData, handleChangeQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const menu = (
|
||||||
|
<Menu>
|
||||||
|
{menuItems.map((menuItem) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={menuItem.key}
|
||||||
|
onClick={(): void => handleSelect(menuItem.key)}
|
||||||
|
>
|
||||||
|
{menuItem.value}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={menu}
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={(val): void => setIsPopoverOpen(val)}
|
||||||
|
>
|
||||||
|
<Tooltip title="Filter by metric type">
|
||||||
|
<Button type="text" shape="circle" icon={<Search size={14} />} />
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetricTypeSearch;
|
@ -80,6 +80,22 @@
|
|||||||
background: var(--bg-ink-400);
|
background: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-name-column-header,
|
||||||
|
.metric-type-column-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.metric-name-column-header-text,
|
||||||
|
.metric-type-column-header-text {
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ant-table-cell {
|
.ant-table-cell {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -263,3 +279,26 @@
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-popover-inner {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-name-search-popover {
|
||||||
|
.metric-name-search-popover-menu {
|
||||||
|
height: 200px;
|
||||||
|
width: 300px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu {
|
||||||
|
.ant-spin,
|
||||||
|
.ant-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -20,15 +20,21 @@ import {
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
||||||
|
import MetricNameSearch from './MetricNameSearch';
|
||||||
|
import MetricTypeSearch from './MetricTypeSearch';
|
||||||
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
|
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
|
||||||
|
|
||||||
export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
|
export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
|
||||||
{
|
{
|
||||||
title: <div className="metric-name-column-header">METRIC</div>,
|
title: (
|
||||||
|
<div className="metric-name-column-header">
|
||||||
|
<span className="metric-name-column-header-text">METRIC</span>
|
||||||
|
<MetricNameSearch />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'metric_name',
|
dataIndex: 'metric_name',
|
||||||
width: 400,
|
width: 400,
|
||||||
sorter: true,
|
sorter: false,
|
||||||
className: 'metric-name-column-header',
|
|
||||||
render: (value: string): React.ReactNode => (
|
render: (value: string): React.ReactNode => (
|
||||||
<div className="metric-name-column-value">{value}</div>
|
<div className="metric-name-column-value">{value}</div>
|
||||||
),
|
),
|
||||||
@ -44,9 +50,14 @@ export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'TYPE',
|
title: (
|
||||||
|
<div className="metric-type-column-header">
|
||||||
|
<span className="metric-type-column-header-text">TYPE</span>
|
||||||
|
<MetricTypeSearch />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'metric_type',
|
dataIndex: 'metric_type',
|
||||||
sorter: true,
|
sorter: false,
|
||||||
width: 150,
|
width: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
getMetricsListFilterValues,
|
||||||
|
MetricsListFilterValuesPayload,
|
||||||
|
MetricsListFilterValuesResponse,
|
||||||
|
} from 'api/metricsExplorer/getMetricsListFilterValues';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
|
||||||
|
type UseGetMetricsListFilterValues = (
|
||||||
|
payload: MetricsListFilterValuesPayload,
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
|
||||||
|
Error
|
||||||
|
>,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
) => UseQueryResult<
|
||||||
|
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
|
||||||
|
Error
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const useGetMetricsListFilterValues: UseGetMetricsListFilterValues = (
|
||||||
|
props,
|
||||||
|
options,
|
||||||
|
headers,
|
||||||
|
) => {
|
||||||
|
const queryKey = useMemo(() => {
|
||||||
|
if (options?.queryKey && Array.isArray(options.queryKey)) {
|
||||||
|
return [...options.queryKey];
|
||||||
|
}
|
||||||
|
return [props];
|
||||||
|
}, [options?.queryKey, props]);
|
||||||
|
|
||||||
|
return useQuery<
|
||||||
|
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
|
||||||
|
Error
|
||||||
|
>({
|
||||||
|
queryFn: ({ signal }) => getMetricsListFilterValues(props, signal, headers),
|
||||||
|
...options,
|
||||||
|
queryKey,
|
||||||
|
});
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user