SagarRajput-7 35c61045c4
feat: added right panel graphs in overview page (#6944)
* feat: added right panel graphs in overview page

* feat: added right panel trace navigation

* feat: implement search column wise and global table wise

* feat: implemented filter on table with api filtering

* feat: added allow clear to table filters

* feat: fixed empty table issue

* feat: fixed celery state - count display
2025-01-28 10:04:06 +05:30

495 lines
12 KiB
TypeScript

import './CeleryOverviewTable.styles.scss';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import {
Button,
Input,
InputRef,
Progress,
Space,
Spin,
TableColumnsType,
TableColumnType,
Tooltip,
Typography,
} from 'antd';
import { FilterDropdownProps } from 'antd/lib/table/interface';
import {
getQueueOverview,
QueueOverviewResponse,
} from 'api/messagingQueues/celery/getQueueOverview';
import { isNumber } from 'chart.js/helpers';
import { ResizeTable } from 'components/ResizeTable';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQuery from 'hooks/useUrlQuery';
import { isEmpty } from 'lodash-es';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const INITIAL_PAGE_SIZE = 20;
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<Typography.Text className="numbers">
{range[0]} &#8212; {range[1]}
</Typography.Text>
<Typography.Text className="total"> of {total}</Typography.Text>
</>
);
export type RowData = {
key: string | number;
[key: string]: string | number;
};
function ProgressRender(item: string | number): JSX.Element {
const percent = Number(Number(item).toFixed(1));
return (
<div className="progress-container">
<Progress
percent={percent}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const cpuPercent = percent;
if (cpuPercent >= 90) return Color.BG_SAKURA_500;
if (cpuPercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</div>
);
}
const getColumnSearchProps = (
searchInput: React.RefObject<InputRef>,
handleReset: (
clearFilters: () => void,
confirm: FilterDropdownProps['confirm'],
) => void,
handleSearch: (selectedKeys: string[], confirm: () => void) => void,
dataIndex?: string,
): TableColumnType<RowData> => ({
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
close,
}): JSX.Element => (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div style={{ padding: 8 }} onKeyDown={(e): void => e.stopPropagation()}>
<Input
ref={searchInput}
placeholder={`Search ${dataIndex}`}
value={selectedKeys[0]}
onChange={(e): void =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={(): void => handleSearch(selectedKeys as string[], confirm)}
style={{ marginBottom: 8, display: 'block' }}
/>
<Space>
<Button
type="primary"
size="small"
onClick={(): void => handleSearch(selectedKeys as string[], confirm)}
icon={<SearchOutlined />}
>
Search
</Button>
<Button
onClick={(): void => clearFilters && handleReset(clearFilters, confirm)}
size="small"
style={{ width: 90 }}
>
Reset
</Button>
<Button
type="link"
size="small"
onClick={(): void => {
close();
}}
>
close
</Button>
</Space>
</div>
),
filterIcon: (filtered: boolean): JSX.Element => (
<SearchOutlined
style={{ color: filtered ? Color.BG_ROBIN_500 : undefined }}
/>
),
onFilter: (value, record): boolean =>
record[dataIndex || '']
.toString()
.toLowerCase()
.includes((value as string).toLowerCase()),
});
function getColumns(data: RowData[]): TableColumnsType<RowData> {
if (data?.length === 0) {
return [];
}
const tooltipRender = (item: string): JSX.Element => (
<Tooltip placement="topLeft" title={item}>
{item}
</Tooltip>
);
return [
{
title: 'SERVICE NAME',
dataIndex: 'service_name',
key: 'service_name',
ellipsis: {
showTitle: false,
},
width: 200,
sorter: (a: RowData, b: RowData): number =>
String(a.service_name).localeCompare(String(b.service_name)),
render: tooltipRender,
fixed: 'left',
},
{
title: 'SPAN NAME',
dataIndex: 'span_name',
key: 'span_name',
ellipsis: {
showTitle: false,
},
width: 200,
sorter: (a: RowData, b: RowData): number =>
String(a.span_name).localeCompare(String(b.span_name)),
render: tooltipRender,
},
{
title: 'MESSAGING SYSTEM',
dataIndex: 'messaging_system',
key: 'messaging_system',
ellipsis: {
showTitle: false,
},
width: 200,
sorter: (a: RowData, b: RowData): number =>
String(a.messaging_system).localeCompare(String(b.messaging_system)),
render: tooltipRender,
},
{
title: 'DESTINATION',
dataIndex: 'destination',
key: 'destination',
ellipsis: {
showTitle: false,
},
render: tooltipRender,
width: 200,
sorter: (a: RowData, b: RowData): number =>
String(a.destination).localeCompare(String(b.destination)),
},
{
title: 'KIND',
dataIndex: 'kind_string',
key: 'kind_string',
ellipsis: {
showTitle: false,
},
width: 100,
sorter: (a: RowData, b: RowData): number =>
String(a.kind_string).localeCompare(String(b.kind_string)),
render: tooltipRender,
},
{
title: 'ERROR %',
dataIndex: 'error_percentage',
key: 'error_percentage',
ellipsis: {
showTitle: false,
},
width: 200,
sorter: (a: RowData, b: RowData): number =>
String(a.error_percentage).localeCompare(String(b.error_percentage)),
render: ProgressRender,
},
{
title: 'LATENCY (P95)',
dataIndex: 'p95_latency',
key: 'p95_latency',
ellipsis: {
showTitle: false,
},
width: 100,
sorter: (a: RowData, b: RowData): number =>
String(a.p95_latency).localeCompare(String(b.p95_latency)),
render: (value: number | string): string => {
if (!isNumber(value)) return value.toString();
return (typeof value === 'string' ? parseFloat(value) : value).toFixed(3);
},
},
{
title: 'THROUGHPUT',
dataIndex: 'throughput',
key: 'throughput',
ellipsis: {
showTitle: false,
},
width: 100,
sorter: (a: RowData, b: RowData): number =>
String(a.throughput).localeCompare(String(b.throughput)),
render: (value: number | string): string => {
if (!isNumber(value)) return value.toString();
return (typeof value === 'string' ? parseFloat(value) : value).toFixed(3);
},
},
];
}
function getTableData(data: QueueOverviewResponse['data']): RowData[] {
if (data?.length === 0) {
return [];
}
const columnOrder = [
'service_name',
'span_name',
'messaging_system',
'destination',
'kind_string',
'error_percentage',
'p95_latency',
'throughput',
];
const tableData: RowData[] =
data?.map(
(row, index: number): RowData => {
const rowData: Record<string, string | number> = {};
columnOrder.forEach((key) => {
const value = row.data[key as keyof typeof row.data];
if (typeof value === 'string' || typeof value === 'number') {
rowData[key] = value;
}
});
Object.entries(row.data).forEach(([key, value]) => {
if (
!columnOrder.includes(key) &&
(typeof value === 'string' || typeof value === 'number')
) {
rowData[key] = value;
}
});
return {
...rowData,
key: index,
};
},
) || [];
return tableData;
}
type Filter = {
key: {
key: string;
dataType: string;
};
op: string;
value: string[];
};
type FilterConfig = {
paramName: string;
operator: string;
key: string;
};
function makeFilters(urlQuery: URLSearchParams): Filter[] {
const filterConfigs: FilterConfig[] = [
{ paramName: QueryParams.destination, key: 'destination', operator: 'in' },
{ paramName: QueryParams.msgSystem, key: 'queue', operator: 'in' },
{ paramName: QueryParams.kindString, key: 'kind_string', operator: 'in' },
{ paramName: QueryParams.service, key: 'service.name', operator: 'in' },
{ paramName: QueryParams.spanName, key: 'name', operator: 'in' },
];
return filterConfigs
.map(({ paramName, operator, key }) => {
const value = urlQuery.get(paramName);
if (!value) return null;
return {
key: {
key,
dataType: 'string',
},
op: operator,
value: value.split(','),
};
})
.filter((filter): filter is Filter => filter !== null);
}
export default function CeleryOverviewTable({
onRowClick,
}: {
onRowClick: (record: RowData) => void;
}): JSX.Element {
const [tableData, setTableData] = useState<RowData[]>([]);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: getOverviewData, isLoading } = useMutation(getQueueOverview, {
onSuccess: (data) => {
if (data?.payload) {
setTableData(getTableData(data?.payload));
} else if (isEmpty(data?.payload)) {
setTableData([]);
}
},
});
const urlQuery = useUrlQuery();
const filters = useMemo(() => makeFilters(urlQuery), [urlQuery]);
useEffect(() => {
getOverviewData({
start: minTime,
end: maxTime,
filters: {
items: filters,
op: 'AND',
},
});
}, [getOverviewData, minTime, maxTime, filters]);
const { draggedColumns, onDragColumns } = useDragColumns<RowData>(
LOCALSTORAGE.CELERY_OVERVIEW_COLUMNS,
);
const [searchText, setSearchText] = useState('');
const searchInput = useRef<InputRef>(null);
const handleSearch = (
selectedKeys: string[],
confirm: FilterDropdownProps['confirm'],
): void => {
confirm();
setSearchText(selectedKeys[0]);
};
const handleReset = (
clearFilters: () => void,
confirm: FilterDropdownProps['confirm'],
): void => {
clearFilters();
setSearchText('');
confirm();
};
const columns = useMemo(
() =>
getDraggedColumns<RowData>(
getColumns(tableData).map((item) => ({
...item,
...getColumnSearchProps(
searchInput,
handleReset,
handleSearch,
item.key?.toString(),
),
})),
draggedColumns,
),
[tableData, draggedColumns],
);
const handleDragColumn = useCallback(
(fromIndex: number, toIndex: number) =>
onDragColumns(columns, fromIndex, toIndex),
[columns, onDragColumns],
);
const paginationConfig = useMemo(
() =>
tableData?.length > INITIAL_PAGE_SIZE && {
pageSize: INITIAL_PAGE_SIZE,
showTotal: showPaginationItem,
showSizeChanger: false,
hideOnSinglePage: true,
},
[tableData],
);
const handleRowClick = (record: RowData): void => {
onRowClick(record);
};
const getFilteredData = useCallback(
(data: RowData[]): RowData[] => {
if (!searchText) return data;
const searchLower = searchText.toLowerCase();
return data.filter((record) =>
Object.values(record).some(
(value) =>
value !== undefined &&
value.toString().toLowerCase().includes(searchLower),
),
);
},
[searchText],
);
const filteredData = useMemo(() => getFilteredData(tableData), [
getFilteredData,
tableData,
]);
return (
<div style={{ width: '100%' }}>
<Input.Search
placeholder="Search across all columns"
onChange={(e): void => setSearchText(e.target.value)}
value={searchText}
allowClear
/>
<ResizeTable
className="celery-overview-table"
pagination={paginationConfig}
size="middle"
columns={columns}
dataSource={filteredData}
bordered={false}
loading={{
spinning: isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: isLoading ? null : <Typography.Text>No data</Typography.Text>,
}}
scroll={{ x: true }}
showSorterTooltip
onDragColumn={handleDragColumn}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className: 'clickable-row',
})}
tableLayout="fixed"
/>
</div>
);
}