mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-18 06:20:34 +01:00
Compare commits
2 Commits
feat/panel
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
056292960c | ||
|
|
232ce0cfab |
@@ -43,4 +43,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.resizableHeader {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
// Grip pinned to the column's trailing edge; the offset straddles the border so
|
||||
// the cursor lands on it without overlapping the adjacent column's content.
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-end: -5px;
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1px;
|
||||
height: 1.6em;
|
||||
background-color: var(--l3-background);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { HTMLAttributes, SyntheticEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Resizable, ResizeCallbackData } from 'react-resizable';
|
||||
|
||||
import styles from './ResizableHeader.module.scss';
|
||||
|
||||
// react-resizable's user-select hack injects a global style that fights antd's
|
||||
// own selection handling; disable it (V1 ResizeTable parity).
|
||||
const DRAGGABLE_OPTS = { enableUserSelectHack: false };
|
||||
// Don't let a column collapse past a usable width.
|
||||
const MIN_WIDTH = 80;
|
||||
|
||||
export interface ResizableHeaderProps extends Omit<
|
||||
HTMLAttributes<HTMLTableCellElement>,
|
||||
'onResize'
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* antd header cell (`components.header.cell`) that wraps the `<th>` in a
|
||||
* react-resizable handle. `width`/`onResize` are injected per column via the
|
||||
* column's `onHeaderCell` (see `useResizableColumns`); a column with neither
|
||||
* renders a plain, non-resizable header.
|
||||
*/
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
() => (
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
return <th {...restProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width}
|
||||
height={0}
|
||||
handle={handle}
|
||||
onResize={onResize}
|
||||
draggableOpts={DRAGGABLE_OPTS}
|
||||
minConstraints={[MIN_WIDTH, 0]}
|
||||
>
|
||||
<th {...restProps} className={styles.resizableHeader} />
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ResizeCallbackData } from 'react-resizable';
|
||||
|
||||
import {
|
||||
readColumnWidths,
|
||||
writeColumnWidths,
|
||||
} from '../../utils/columnWidthStorage';
|
||||
import { useResizableColumns } from '../useResizableColumns';
|
||||
|
||||
type Row = { key: string; name: string };
|
||||
|
||||
const COLUMNS: ColumnsType<Row> = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Status', dataIndex: 'status', key: 'status' },
|
||||
];
|
||||
|
||||
// Invokes a column's resize handler the way antd's header cell would.
|
||||
function resize(column: ColumnsType<Row>[number], width: number): void {
|
||||
const headerProps = (
|
||||
column.onHeaderCell as (c: unknown) => {
|
||||
onResize?: (e: unknown, data: ResizeCallbackData) => void;
|
||||
}
|
||||
)(column);
|
||||
headerProps.onResize?.({}, {
|
||||
size: { width, height: 0 },
|
||||
} as ResizeCallbackData);
|
||||
}
|
||||
|
||||
describe('useResizableColumns', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('falls back to the default width when nothing is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns<Row>({ panelId: 'p1', columns: COLUMNS }),
|
||||
);
|
||||
expect(result.current.columns.map((c) => c.width)).toStrictEqual([180, 180]);
|
||||
});
|
||||
|
||||
it('leaves a flex column width-less (no default) so it fills remaining space', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns<Row>({
|
||||
panelId: 'p1',
|
||||
columns: COLUMNS,
|
||||
flexColumns: ['status'],
|
||||
}),
|
||||
);
|
||||
const widths = Object.fromEntries(
|
||||
result.current.columns.map((c) => [c.key, c.width]),
|
||||
);
|
||||
// name gets the default; the flex column stays undefined.
|
||||
expect(widths).toStrictEqual({ name: 180, status: undefined });
|
||||
});
|
||||
|
||||
it('honors a stored width on a flex column once the user resizes it', () => {
|
||||
writeColumnWidths('p1', { status: 300 });
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns<Row>({
|
||||
panelId: 'p1',
|
||||
columns: COLUMNS,
|
||||
flexColumns: ['status'],
|
||||
}),
|
||||
);
|
||||
const widths = Object.fromEntries(
|
||||
result.current.columns.map((c) => [c.key, c.width]),
|
||||
);
|
||||
expect(widths).toStrictEqual({ name: 180, status: 300 });
|
||||
});
|
||||
|
||||
it('seeds a column with its stored width', () => {
|
||||
writeColumnWidths('p1', { name: 250 });
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns<Row>({ panelId: 'p1', columns: COLUMNS }),
|
||||
);
|
||||
const widths = Object.fromEntries(
|
||||
result.current.columns.map((c) => [c.key, c.width]),
|
||||
);
|
||||
expect(widths).toStrictEqual({ name: 250, status: 180 });
|
||||
});
|
||||
|
||||
it('updates the width on resize and persists it (debounced)', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns<Row>({ panelId: 'p1', columns: COLUMNS }),
|
||||
);
|
||||
|
||||
act(() => resize(result.current.columns[0], 321));
|
||||
|
||||
expect(result.current.columns[0].width).toBe(321);
|
||||
// Not yet flushed to storage.
|
||||
expect(readColumnWidths('p1')).toStrictEqual({});
|
||||
|
||||
act(() => jest.advanceTimersByTime(400));
|
||||
expect(readColumnWidths('p1')).toStrictEqual({ name: 321 });
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { TableProps } from 'antd';
|
||||
import type { ResizeCallbackData } from 'react-resizable';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import ResizableHeader, {
|
||||
ResizableHeaderProps,
|
||||
} from '../components/ResizableTable/ResizableHeader';
|
||||
import {
|
||||
ColumnWidths,
|
||||
readColumnWidths,
|
||||
writeColumnWidths,
|
||||
} from '../utils/columnWidthStorage';
|
||||
|
||||
type Columns<T> = NonNullable<TableProps<T>['columns']>;
|
||||
type Column<T> = Columns<T>[number];
|
||||
|
||||
// Starting width for a column with no stored or column-declared width. Also the
|
||||
// floor that makes every column resizable (a column needs a width to get a grip).
|
||||
const DEFAULT_COLUMN_WIDTH = 180;
|
||||
// Coalesce the burst of resize events during a drag into a single write.
|
||||
const PERSIST_DEBOUNCE_MS = 400;
|
||||
|
||||
// A column's key for width storage: its antd `key`, falling back to `dataIndex`.
|
||||
// Both Table and List columns set `key`, so this is effectively always present.
|
||||
function getColumnKey<T>(column: Column<T>): string | undefined {
|
||||
const raw =
|
||||
'key' in column && column.key != null
|
||||
? column.key
|
||||
: (column as { dataIndex?: unknown }).dataIndex;
|
||||
if (typeof raw === 'string') {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === 'number') {
|
||||
return String(raw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface UseResizableColumnsArgs<T> {
|
||||
/** Scopes the persisted widths so each panel resizes independently. */
|
||||
panelId: string;
|
||||
columns: Columns<T>;
|
||||
defaultWidth?: number;
|
||||
/**
|
||||
* Column keys that should stay width-less (flexible) unless the user has
|
||||
* resized them — they absorb the remaining table width under `tableLayout:
|
||||
* fixed` so the table fits its container (e.g. the List panel's `body`).
|
||||
*/
|
||||
flexColumns?: string[];
|
||||
}
|
||||
|
||||
interface UseResizableColumnsResult<T> {
|
||||
columns: Columns<T>;
|
||||
components: TableProps<T>['components'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an antd Table's columns user-resizable, persisting each panel's widths to
|
||||
* localStorage so they survive reloads. Returns the merged columns (seeded with
|
||||
* stored/default widths and an `onHeaderCell` resize handler) plus the `components`
|
||||
* override that swaps in the resizable header cell. Shared by the Table and List
|
||||
* panel renderers.
|
||||
*/
|
||||
export function useResizableColumns<T>({
|
||||
panelId,
|
||||
columns,
|
||||
defaultWidth = DEFAULT_COLUMN_WIDTH,
|
||||
flexColumns,
|
||||
}: UseResizableColumnsArgs<T>): UseResizableColumnsResult<T> {
|
||||
const flexKeys = useMemo(() => new Set(flexColumns ?? []), [flexColumns]);
|
||||
// Live widths keyed by column key. Seeded from localStorage so a resized
|
||||
// panel renders at its saved widths on first paint.
|
||||
const [widths, setWidths] = useState<ColumnWidths>(() =>
|
||||
readColumnWidths(panelId),
|
||||
);
|
||||
|
||||
// The same renderer instance can be reused across panels (e.g. switching the
|
||||
// edited panel), so re-seed from storage whenever the panel identity changes.
|
||||
useEffect(() => {
|
||||
setWidths(readColumnWidths(panelId));
|
||||
}, [panelId]);
|
||||
|
||||
// Debounced persist, recreated per panel so a trailing call always writes to
|
||||
// the right entry; cancelled on unmount/panel change to drop pending writes.
|
||||
const persist = useMemo(
|
||||
() =>
|
||||
debounce((next: ColumnWidths) => {
|
||||
writeColumnWidths(panelId, next);
|
||||
}, PERSIST_DEBOUNCE_MS),
|
||||
[panelId],
|
||||
);
|
||||
useEffect(() => (): void => persist.cancel(), [persist]);
|
||||
|
||||
// Mirror the latest widths so the resize handler can derive the next map
|
||||
// without depending on (and thus rebuilding) on every width change.
|
||||
const widthsRef = useRef(widths);
|
||||
widthsRef.current = widths;
|
||||
|
||||
const handleResize = useCallback(
|
||||
(key: string) =>
|
||||
(_e: SyntheticEvent<Element>, { size }: ResizeCallbackData): void => {
|
||||
const next = { ...widthsRef.current, [key]: Math.round(size.width) };
|
||||
widthsRef.current = next;
|
||||
setWidths(next);
|
||||
persist(next);
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const resizableColumns = useMemo<Columns<T>>(
|
||||
() =>
|
||||
columns.map((column) => {
|
||||
const key = getColumnKey(column);
|
||||
// Flex columns stay width-less (so they fill the remaining width) until
|
||||
// the user resizes them; everything else falls back to the default.
|
||||
const isFlex = key !== undefined && flexKeys.has(key);
|
||||
const width =
|
||||
(key ? widths[key] : undefined) ??
|
||||
(column.width as number | undefined) ??
|
||||
(isFlex ? undefined : defaultWidth);
|
||||
return {
|
||||
...column,
|
||||
width,
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
[columns, widths, defaultWidth, flexKeys, handleResize],
|
||||
);
|
||||
|
||||
const components = useMemo<TableProps<T>['components']>(
|
||||
() => ({ header: { cell: ResizableHeader } }),
|
||||
[],
|
||||
);
|
||||
|
||||
return { columns: resizableColumns, components };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { useResizableColumns } from '../../hooks/useResizableColumns';
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
|
||||
import { computeTableLayout, filterTableRows } from '../../utils/recordTable';
|
||||
|
||||
import { buildTableColumns, mapTableThresholds } from './tableColumns';
|
||||
|
||||
import styles from './TablePanel.module.scss';
|
||||
|
||||
type TableRowData = Record<string, unknown> & { key: number };
|
||||
|
||||
function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
searchTerm = '',
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) and the
|
||||
// header stays pinned while the body scrolls.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { pageSize, scrollY } = useMemo(
|
||||
() => computeTableLayout(height),
|
||||
[height],
|
||||
);
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
// V5 joins every query into a single scalar result, so the first non-empty
|
||||
// table is the whole panel.
|
||||
const table = useMemo(
|
||||
() =>
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data?.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}).find((candidate) => candidate.columns.length > 0),
|
||||
[data.response, data.legendMap, data.requestPayload],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const thresholdsByColumn = useMemo(
|
||||
() => mapTableThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
table
|
||||
? buildTableColumns({
|
||||
table,
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
})
|
||||
: [],
|
||||
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel to localStorage.
|
||||
const { columns: resizableColumns, components } = useResizableColumns({
|
||||
panelId,
|
||||
columns: columns ?? [],
|
||||
});
|
||||
|
||||
const dataSource = useMemo<TableRowData[]>(
|
||||
() =>
|
||||
table ? table.rows.map((row, index) => ({ key: index, ...row.data })) : [],
|
||||
[table],
|
||||
);
|
||||
|
||||
// Header search filters rows client-side (V1 parity). Falls back to the full
|
||||
// set when the term is empty, so non-searching tables pay nothing.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
);
|
||||
|
||||
// Keep pagination in range as the filtered set shrinks: a new term snaps back
|
||||
// to the first page so the user never lands on a now-empty page.
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [searchTerm]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="table-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
size="small"
|
||||
columns={resizableColumns}
|
||||
components={components}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
hideOnSinglePage: true,
|
||||
size: 'small',
|
||||
onChange: setPage,
|
||||
}}
|
||||
scroll={{ x: 'max-content', y: scrollY }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TablePanelRenderer;
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Scroll the table within the panel rather than letting it grow the card.
|
||||
.container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
// Match the chart legend scrollbar (shared mixin).
|
||||
@include custom-scrollbar;
|
||||
|
||||
// antd renders its own scroll containers when `scroll={{ x, y }}` is set —
|
||||
// the pinned-header body (vertical) and the content (horizontal). Match them
|
||||
// to the legend scrollbar too.
|
||||
:global(.ant-table-body),
|
||||
:global(.ant-table-content) {
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesTablePanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import TablePanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTablePanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// V5 scalar response: one joined result with a group column + an aggregation column.
|
||||
function dataWith(rows: [string, number][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{ name: 'service.name', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyData: PanelQueryData = {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [] } },
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
...props,
|
||||
};
|
||||
return render(<TablePanelRenderer {...baseProps} />);
|
||||
}
|
||||
|
||||
describe('TablePanelRenderer', () => {
|
||||
it('renders the group column header and its row values', () => {
|
||||
const { getByText } = renderPanel({
|
||||
data: dataWith([
|
||||
['frontend', 1234],
|
||||
['cartservice', 5678],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(getByText('service.name')).toBeInTheDocument();
|
||||
expect(getByText('frontend')).toBeInTheDocument();
|
||||
expect(getByText('cartservice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response has no scalar results', () => {
|
||||
const { getByTestId } = renderPanel({ data: emptyData });
|
||||
|
||||
expect(getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response is absent', () => {
|
||||
const { getByTestId } = renderPanel({
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
});
|
||||
|
||||
expect(getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters rows to those matching the search term (case-insensitive)', () => {
|
||||
const { getByText, queryByText } = renderPanel({
|
||||
data: dataWith([
|
||||
['frontend', 1234],
|
||||
['cartservice', 5678],
|
||||
]),
|
||||
searchTerm: 'CART',
|
||||
});
|
||||
|
||||
expect(getByText('cartservice')).toBeInTheDocument();
|
||||
expect(queryByText('frontend')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the table mounted (not No Data) when the search matches no rows', () => {
|
||||
const { getByTestId, queryByText } = renderPanel({
|
||||
data: dataWith([['frontend', 1234]]),
|
||||
searchTerm: 'no-such-row',
|
||||
});
|
||||
|
||||
expect(getByTestId('table-panel-renderer')).toBeInTheDocument();
|
||||
expect(queryByText('frontend')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
createAlert: false,
|
||||
},
|
||||
// V1 parity: only tables expose the header search box.
|
||||
headerControls: { search: true },
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A table panel renders one scalar result (the V5 backend joins every query into a
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
// per-column units), per-column thresholds, and context links.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { TableProps } from 'antd';
|
||||
import type { DashboardtypesTableThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import { resolveActiveThreshold } from '../../utils/evaluateThresholds';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
|
||||
|
||||
type TableRowData = Record<string, unknown>;
|
||||
|
||||
// Resolve a column's unit by its key (queryName / queryName.expression), falling
|
||||
// back to the base query name for the legacy `queryName.expression` syntax — mirrors
|
||||
// V1 `getColumnUnit`. An empty entry means "no unit".
|
||||
function getColumnUnit(
|
||||
key: string,
|
||||
columnUnits: Record<string, string>,
|
||||
): string | undefined {
|
||||
if (columnUnits[key] !== undefined) {
|
||||
return columnUnits[key] || undefined;
|
||||
}
|
||||
if (key.includes('.')) {
|
||||
const baseQuery = key.split('.')[0];
|
||||
if (columnUnits[baseQuery] !== undefined) {
|
||||
return columnUnits[baseQuery] || undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Safely render a raw cell value (typed `unknown`) as text: primitives stringify
|
||||
// directly, objects fall back to JSON, nullish to empty.
|
||||
function stringifyCell(raw: unknown): string {
|
||||
if (raw == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
return JSON.stringify(raw);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') {
|
||||
return String(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups table thresholds by the column they target, mapping each onto the
|
||||
* V2-native `PanelThreshold` consumed by `resolveActiveThreshold`. A column with
|
||||
* no thresholds simply has no entry.
|
||||
*/
|
||||
export function mapTableThresholds(
|
||||
thresholds: DashboardtypesTableThresholdDTO[] | null | undefined,
|
||||
): Record<string, PanelThreshold[]> {
|
||||
const byColumn: Record<string, PanelThreshold[]> = {};
|
||||
(thresholds ?? []).forEach((threshold) => {
|
||||
(byColumn[threshold.columnName] ??= []).push(toPanelThreshold(threshold));
|
||||
});
|
||||
return byColumn;
|
||||
}
|
||||
|
||||
// Sort comparator: numeric when both cells parse as numbers (value columns and
|
||||
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
|
||||
function compareCells(a: unknown, b: unknown): number {
|
||||
const aNum = Number(a);
|
||||
const bNum = Number(b);
|
||||
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
if (a == null) {
|
||||
return b == null ? 0 : 1;
|
||||
}
|
||||
if (b == null) {
|
||||
return -1;
|
||||
}
|
||||
return stringifyCell(a).localeCompare(stringifyCell(b));
|
||||
}
|
||||
|
||||
export interface BuildTableColumnsArgs {
|
||||
table: PanelTable;
|
||||
/** Per-column display unit (`formatting.columnUnits`), keyed by column name. */
|
||||
columnUnits: Record<string, string>;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Thresholds grouped by column name (see `mapTableThresholds`). */
|
||||
thresholdsByColumn: Record<string, PanelThreshold[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds antd Table columns from a prepared scalar table. Value columns format
|
||||
* their cell through the column's unit + decimal precision and recolor via the
|
||||
* matching threshold (text → font colour, background → cell fill); group columns
|
||||
* render their raw label. Every column is sortable.
|
||||
*/
|
||||
export function buildTableColumns({
|
||||
table,
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
|
||||
return table.columns.map((col) => {
|
||||
// Column key = query identifier for value columns, group name otherwise. Units
|
||||
// and thresholds are stored against this key (V1 parity), matching the dataIndex.
|
||||
const key = col.id || col.name;
|
||||
const unit = getColumnUnit(key, columnUnits);
|
||||
const colThresholds = thresholdsByColumn[key] ?? [];
|
||||
|
||||
return {
|
||||
title: col.name,
|
||||
dataIndex: key,
|
||||
key,
|
||||
sorter: (a: TableRowData, b: TableRowData): number =>
|
||||
compareCells(a[key], b[key]),
|
||||
render: (raw: unknown): React.ReactNode => {
|
||||
if (!col.isValueColumn) {
|
||||
return stringifyCell(raw);
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return stringifyCell(raw);
|
||||
}
|
||||
const text = formatPanelValue(num, unit, decimalPrecision);
|
||||
if (colThresholds.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'text') {
|
||||
return <span style={{ color: threshold.color }}>{text}</span>;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
|
||||
if (!col.isValueColumn || colThresholds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const num = Number(record[key]);
|
||||
if (!Number.isFinite(num)) {
|
||||
return {};
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
return { style: { backgroundColor: threshold.color } };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { definition as Histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
import { definition as PieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
@@ -18,6 +19,7 @@ export const PANELS: PanelRegistry = {
|
||||
[Histogram.kind]: Histogram,
|
||||
[NumberValue.kind]: NumberValue,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Table.kind]: Table,
|
||||
};
|
||||
|
||||
export function getPanelDefinition(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readColumnWidths, writeColumnWidths } from '../columnWidthStorage';
|
||||
|
||||
describe('columnWidthStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns an empty map when nothing is stored', () => {
|
||||
expect(readColumnWidths('panel-1')).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('round-trips widths for a panel', () => {
|
||||
writeColumnWidths('panel-1', { name: 200, status: 80 });
|
||||
expect(readColumnWidths('panel-1')).toStrictEqual({ name: 200, status: 80 });
|
||||
});
|
||||
|
||||
it('keeps each panel isolated', () => {
|
||||
writeColumnWidths('panel-1', { a: 100 });
|
||||
writeColumnWidths('panel-2', { b: 300 });
|
||||
|
||||
expect(readColumnWidths('panel-1')).toStrictEqual({ a: 100 });
|
||||
expect(readColumnWidths('panel-2')).toStrictEqual({ b: 300 });
|
||||
});
|
||||
|
||||
it('overwrites a panel without touching the others', () => {
|
||||
writeColumnWidths('panel-1', { a: 100 });
|
||||
writeColumnWidths('panel-2', { b: 300 });
|
||||
writeColumnWidths('panel-1', { a: 150, c: 50 });
|
||||
|
||||
expect(readColumnWidths('panel-1')).toStrictEqual({ a: 150, c: 50 });
|
||||
expect(readColumnWidths('panel-2')).toStrictEqual({ b: 300 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
/** Resized column widths for a single panel, keyed by column key. */
|
||||
export type ColumnWidths = Record<string, number>;
|
||||
|
||||
// All panels' widths live under one localStorage key, keyed by panelId, so the
|
||||
// store is a single read/write rather than one entry per panel.
|
||||
type ColumnWidthStore = Record<string, ColumnWidths>;
|
||||
|
||||
function readStore(): ColumnWidthStore {
|
||||
try {
|
||||
const raw = getLocalStorageApi(LOCALSTORAGE.DASHBOARD_V2_PANEL_COLUMN_WIDTHS);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as ColumnWidthStore)
|
||||
: {};
|
||||
} catch {
|
||||
// Malformed JSON or storage access denied — fall back to no stored widths.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the stored widths for one panel (empty when none persisted yet). */
|
||||
export function readColumnWidths(panelId: string): ColumnWidths {
|
||||
return readStore()[panelId] ?? {};
|
||||
}
|
||||
|
||||
/** Persists the widths for one panel, leaving every other panel's entry intact. */
|
||||
export function writeColumnWidths(panelId: string, widths: ColumnWidths): void {
|
||||
const store = readStore();
|
||||
store[panelId] = widths;
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.DASHBOARD_V2_PANEL_COLUMN_WIDTHS,
|
||||
JSON.stringify(store),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Shared chrome for the antd-table panel kinds (Table, List): page-size/scroll
|
||||
// sizing from the measured panel, and the header search-box row filter. Both
|
||||
// kinds render record rows in an antd Table, so this lives here rather than in
|
||||
// any one kind's folder.
|
||||
|
||||
// antd small-table row / header / pagination heights (px), used to estimate how
|
||||
// many rows fit the panel so each page roughly fills the available space.
|
||||
const ROW_HEIGHT = 39;
|
||||
const HEADER_HEIGHT = 39;
|
||||
const PAGINATION_HEIGHT = 56;
|
||||
|
||||
export const MIN_PAGE_SIZE = 10;
|
||||
|
||||
export interface TableLayout {
|
||||
/** Rows per page — at least MIN_PAGE_SIZE, otherwise as many as fit. */
|
||||
pageSize: number;
|
||||
/** tbody scroll height that keeps the header pinned; undefined before measure. */
|
||||
scrollY: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the table's page size and scroll body from the panel's measured
|
||||
* height: reserve the header + pagination, then fit whole rows into what's left
|
||||
* (never fewer than MIN_PAGE_SIZE). Before the panel is measured (`height` 0),
|
||||
* fall back to the minimum page size and let the body render at natural height.
|
||||
*/
|
||||
export function computeTableLayout(height: number): TableLayout {
|
||||
if (!height) {
|
||||
return { pageSize: MIN_PAGE_SIZE, scrollY: undefined };
|
||||
}
|
||||
const body = Math.max(ROW_HEIGHT, height - HEADER_HEIGHT - PAGINATION_HEIGHT);
|
||||
return {
|
||||
pageSize: Math.max(MIN_PAGE_SIZE, Math.floor(body / ROW_HEIGHT)),
|
||||
scrollY: body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side row filter for the header search box (V1 parity): keeps a row when
|
||||
* any cell's stringified value contains `term`, case-insensitively. The synthetic
|
||||
* antd `key` is skipped so row indices never match. An empty term is a no-op and
|
||||
* returns the original array (stable reference for memoization).
|
||||
*/
|
||||
export function filterTableRows<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
term: string,
|
||||
): T[] {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
Object.entries(row).some(
|
||||
([key, value]) =>
|
||||
key !== 'key' && String(value).toLowerCase().includes(needle),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user