Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
056292960c feat(dashboards-v2): resizable, persisted table columns 2026-06-18 05:27:03 +05:30
Abhi Kumar
232ce0cfab feat(dashboards-v2): table panel renderer with record-table prep 2026-06-18 05:27:03 +05:30
15 changed files with 949 additions and 0 deletions

View File

@@ -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',
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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();
}
});
});

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -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 },
};

View File

@@ -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' },
];

View File

@@ -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 {};
},
};
});
}

View File

@@ -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(

View File

@@ -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 });
});
});

View File

@@ -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),
);
}

View File

@@ -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),
),
);
}