diff --git a/timeseriestable/cue.mod/module.cue b/timeseriestable/cue.mod/module.cue index 2d00ce16..7738ef10 100644 --- a/timeseriestable/cue.mod/module.cue +++ b/timeseriestable/cue.mod/module.cue @@ -5,3 +5,9 @@ language: { source: { kind: "git" } +deps: { + "github.com/perses/shared/cue@v0": { + v: "v0.53.0-rc.2" + default: true + } +} diff --git a/timeseriestable/schemas/time-series-table.cue b/timeseriestable/schemas/time-series-table.cue index 02e0df45..19142956 100644 --- a/timeseriestable/schemas/time-series-table.cue +++ b/timeseriestable/schemas/time-series-table.cue @@ -13,5 +13,12 @@ package model +import ( + "github.com/perses/shared/cue/common" +) + kind: "TimeSeriesTable" -spec: close({}) +spec: close({ + selection?: common.#selection + actions?: common.#actions +}) diff --git a/timeseriestable/src/TimeSeriesTable.ts b/timeseriestable/src/TimeSeriesTable.ts index 0738b601..b08836aa 100644 --- a/timeseriestable/src/TimeSeriesTable.ts +++ b/timeseriestable/src/TimeSeriesTable.ts @@ -13,6 +13,7 @@ import { PanelPlugin } from '@perses-dev/plugin-system'; import { TimeSeriesTablePanel, TimeSeriesTableProps } from './TimeSeriesTablePanel'; +import { TimeSeriesTableItemSelectionActionsEditor } from './components'; import { TimeSeriesTableOptions } from './model'; /** @@ -24,6 +25,7 @@ export const TimeSeriesTable: PanelPlugin { return {}; }, diff --git a/timeseriestable/src/TimeSeriesTablePanel.test.tsx b/timeseriestable/src/TimeSeriesTablePanel.test.tsx index 5876921a..32c11698 100644 --- a/timeseriestable/src/TimeSeriesTablePanel.test.tsx +++ b/timeseriestable/src/TimeSeriesTablePanel.test.tsx @@ -11,9 +11,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ChartsProvider, SnackbarProvider, testChartsTheme } from '@perses-dev/components'; +import { + ChartsProvider, + ItemActionsProvider, + SelectionProvider, + SnackbarProvider, + testChartsTheme, +} from '@perses-dev/components'; import { TimeSeriesData } from '@perses-dev/core'; +import { VariableProvider } from '@perses-dev/dashboards'; +import { TimeRangeProviderBasic, WebhookAction } from '@perses-dev/plugin-system'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TimeSeriesTableOptions } from './model'; import { MOCK_TIME_SERIES_DATA_MULTIVALUE, MOCK_TIME_SERIES_DATA_SINGLEVALUE, @@ -21,57 +32,153 @@ import { } from './test/mock-query-results'; import { TimeSeriesTablePanel, TimeSeriesTableProps } from './TimeSeriesTablePanel'; -const TEST_TIME_SERIES_TABLE_PROPS: Omit = { - contentDimensions: { - width: 500, - height: 500, - }, +const TEST_PROPS: Omit = { + contentDimensions: { width: 500, height: 500 }, spec: {}, }; +const TEST_WEBHOOK_ACTION: WebhookAction = { + type: 'webhook', + name: 'Test Action', + url: 'https://example.com/action', + method: 'POST', + contentType: 'json', + batchMode: 'individual', + enabled: true, +}; + +const EXPECTED_SERIES_TEXT = + '{device="/dev/vda1", env="demo", fstype="ext4", instance="demo.do.prometheus.io:9100", job="node", mountpoint="/"}'; + describe('TimeSeriesTablePanel', () => { - // Helper to render the panel with some context set - const renderPanel = (data: TimeSeriesData): void => { + const renderPanel = (data: TimeSeriesData, options?: TimeSeriesTableOptions) => { render( - - - - - + + + + + + + + + + + + + + + ); }; + const getCheckboxes = () => screen.findAllByRole('checkbox'); + it('should render multi values with timestamps', async () => { renderPanel(MOCK_TIME_SERIES_DATA_MULTIVALUE); - expect( - screen.getAllByText( - (_, element) => - element?.textContent === - '{device="/dev/vda1", env="demo", fstype="ext4", instance="demo.do.prometheus.io:9100", job="node", mountpoint="/"}' - ).length - ).toBeGreaterThan(0); - - expect(await screen.findAllByRole('cell')).toHaveLength(4); // 2 lines with 2 column - expect(await screen.findAllByText('@1666479357903')).toHaveLength(2); // first timestamp appear once per line - expect(await screen.findAllByText('@1666479382282')).toHaveLength(2); // second timestamp appear once per line + expect(screen.getAllByText((_, el) => el?.textContent === EXPECTED_SERIES_TEXT).length).toBeGreaterThan(0); + expect(await screen.findAllByRole('cell')).toHaveLength(4); + expect(await screen.findAllByText('@1666479357903')).toHaveLength(2); + expect(await screen.findAllByText('@1666479382282')).toHaveLength(2); }); it('should render single value without timestamp', async () => { renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE); - expect( - screen.getAllByText( - (_, element) => - element?.textContent === - '{device="/dev/vda1", env="demo", fstype="ext4", instance="demo.do.prometheus.io:9100", job="node", mountpoint="/"}' - ).length - ).toBeGreaterThan(0); + expect(screen.getAllByText((_, el) => el?.textContent === EXPECTED_SERIES_TEXT).length).toBeGreaterThan(0); + expect(await screen.findAllByRole('cell')).toHaveLength(4); + expect(screen.queryByText('@')).toBeNull(); + }); + + describe('Selection', () => { + it('should not render checkboxes when disabled', () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: false } }); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); + + it('should render checkboxes when enabled', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: true } }); + expect(await getCheckboxes()).toHaveLength(3); // 1 header + 2 rows + }); + + it('should render table header with column labels', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: true } }); + + expect(await screen.findByRole('columnheader', { name: 'Series' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: 'Value' })).toBeInTheDocument(); + }); + + it('should toggle row selection when clicking checkbox', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: true } }); + + const [, rowCheckbox] = await getCheckboxes(); + expect(rowCheckbox).not.toBeChecked(); + + userEvent.click(rowCheckbox!); + expect(rowCheckbox).toBeChecked(); + + userEvent.click(rowCheckbox!); + expect(rowCheckbox).not.toBeChecked(); + }); + + it('should select all rows when clicking header checkbox', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: true } }); + + const [selectAll, row1, row2] = await getCheckboxes(); + expect(selectAll).not.toBeChecked(); + + userEvent.click(selectAll!); + expect(selectAll).toBeChecked(); + expect(row1).toBeChecked(); + expect(row2).toBeChecked(); + }); + + it('should deselect all rows when clicking header checkbox while all selected', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { selection: { enabled: true } }); + + const [selectAll] = await getCheckboxes(); + userEvent.click(selectAll!); + expect(selectAll).toBeChecked(); + + userEvent.click(selectAll!); + expect(selectAll).not.toBeChecked(); + + const [, row1, row2] = await getCheckboxes(); + expect(row1).not.toBeChecked(); + expect(row2).not.toBeChecked(); + }); + }); + + describe('Item Actions', () => { + const actionsConfig = (name = 'Test Action'): TimeSeriesTableOptions => ({ + selection: { enabled: true }, + actions: { + enabled: true, + displayWithItem: true, + actionsList: [{ ...TEST_WEBHOOK_ACTION, name }], + }, + }); + + it('should not render actions column when disabled', () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, { + selection: { enabled: true }, + actions: { enabled: false }, + }); + expect(screen.queryByRole('columnheader', { name: /Actions/i })).not.toBeInTheDocument(); + }); + + it('should render actions column when enabled with displayWithItem', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, actionsConfig()); + expect(await screen.findByRole('columnheader', { name: /Actions/i })).toBeInTheDocument(); + }); - expect(await screen.findAllByRole('cell')).toHaveLength(4); // 2 lines with 2 column - expect(screen.queryByText('@')).toBeNull(); // No @ as no timestamp + it('should render action buttons for each row', async () => { + renderPanel(MOCK_TIME_SERIES_DATA_SINGLEVALUE, actionsConfig('My Test Action')); + expect(await screen.findAllByRole('button', { name: /My Test Action/i })).toHaveLength(2); + }); }); }); diff --git a/timeseriestable/src/TimeSeriesTablePanel.tsx b/timeseriestable/src/TimeSeriesTablePanel.tsx index af50a1b7..d8531526 100644 --- a/timeseriestable/src/TimeSeriesTablePanel.tsx +++ b/timeseriestable/src/TimeSeriesTablePanel.tsx @@ -23,7 +23,7 @@ import { DataTable } from './components'; export type TimeSeriesTableProps = PanelProps; export function TimeSeriesTablePanel(props: TimeSeriesTableProps): ReactElement { - const { contentDimensions, queryResults } = props; + const { contentDimensions, queryResults, spec } = props; const chartsTheme = useChartsTheme(); const contentPadding = chartsTheme.container.padding.default; @@ -32,7 +32,7 @@ export function TimeSeriesTablePanel(props: TimeSeriesTableProps): ReactElement style={{ height: contentDimensions?.height ?? 0 }} sx={{ padding: `${contentPadding}px`, overflowY: 'scroll' }} > - + ); } diff --git a/timeseriestable/src/components/DataTable.tsx b/timeseriestable/src/components/DataTable.tsx index 8f7da4b7..fc675b4e 100644 --- a/timeseriestable/src/components/DataTable.tsx +++ b/timeseriestable/src/components/DataTable.tsx @@ -11,17 +11,45 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement, ReactNode, useMemo } from 'react'; -import { Alert, Box, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; -import { TimeSeries, TimeSeriesData, BucketTuple, TimeSeriesHistogramTuple, HistogramValue } from '@perses-dev/core'; -import { PanelData } from '@perses-dev/plugin-system'; -import { SeriesName } from './SeriesName'; +import { + Alert, + Box, + Checkbox, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useSelection } from '@perses-dev/components'; +import { BucketTuple, HistogramValue, TimeSeries, TimeSeriesData, TimeSeriesHistogramTuple } from '@perses-dev/core'; +import { useSelectionItemActions } from '@perses-dev/dashboards'; +import { ActionOptions, PanelData, useAllVariableValues } from '@perses-dev/plugin-system'; +import { ReactElement, ReactNode, useCallback, useMemo } from 'react'; +import { TimeSeriesTableOptions } from '../model'; import { EmbeddedPanel } from './EmbeddedPanel'; +import { SeriesName } from './SeriesName'; const MAX_FORMATTABLE_SERIES = 1000; export interface DataTableProps { queryResults: Array>; + spec: TimeSeriesTableOptions; +} + +/** + * Build row data object from a TimeSeries, including all labels and value + */ +function buildRowData(ts: TimeSeries): Record { + return { + ...ts.labels, + name: ts.name, + formattedName: ts.formattedName, + value: ts.values?.[0]?.[1], + timestamp: ts.values?.[0]?.[0], + }; } /** @@ -32,11 +60,71 @@ export interface DataTableProps { * @param result timeseries query result * @constructor */ -export const DataTable = ({ queryResults }: DataTableProps): ReactElement | null => { +export const DataTable = ({ queryResults, spec }: DataTableProps): ReactElement | null => { + const allVariables = useAllVariableValues(); const series = useMemo(() => queryResults.flatMap((d) => d.data).flatMap((d) => d?.series || []), [queryResults]); - const rows = useMemo(() => buildRows(series, queryResults), [series, queryResults]); - if (!queryResults || !rows?.length) { + const selectionEnabled = spec.selection?.enabled ?? false; + const { selectionMap, setSelection, clearSelection, toggleSelection } = useSelection< + Record, + string + >(); + + const itemActionsConfig = spec.actions ? (spec.actions as ActionOptions) : undefined; + const itemActionsListConfig = useMemo( + () => (itemActionsConfig?.enabled && itemActionsConfig.displayWithItem ? itemActionsConfig.actionsList : []), + [itemActionsConfig?.enabled, itemActionsConfig?.displayWithItem, itemActionsConfig?.actionsList] + ); + + const { getItemActionButtons, confirmDialog, actionButtons } = useSelectionItemActions({ + actions: itemActionsListConfig, + variableState: allVariables, + }); + + const hasItemActions = actionButtons && actionButtons.length > 0; + + const allSelected = useMemo(() => { + if (series.length === 0) return false; + return series.every((_, idx) => selectionMap.has(idx.toString())); + }, [series, selectionMap]); + + // Check if some (but not all) series are selected + const someSelected = useMemo(() => { + if (series.length === 0) return false; + const selectedCount = series.filter((_, idx) => selectionMap.has(idx.toString())).length; + return selectedCount > 0 && selectedCount < series.length; + }, [series, selectionMap]); + + const handleSelectAll = useCallback(() => { + if (allSelected) { + clearSelection(); + } else { + const allItems = series.map((ts, idx) => ({ + id: idx.toString(), + item: buildRowData(ts), + })); + setSelection(allItems); + } + }, [allSelected, series, setSelection, clearSelection]); + + const handleRowSelectionToggle = useCallback( + (ts: TimeSeries, seriesIdx: number) => { + const rowData = buildRowData(ts); + toggleSelection(rowData, seriesIdx.toString()); + }, + [toggleSelection] + ); + + // Memoize row data for stable references + const rowsData = useMemo(() => { + return series.map((ts, idx) => ({ + ts, + idx, + rowData: buildRowData(ts), + })); + }, [series]); + + if (!queryResults || !series?.length) { return ( + {confirmDialog} {series.length >= MAX_FORMATTABLE_SERIES && ( Showing more than {MAX_FORMATTABLE_SERIES} series, turning off label formatting for performance reasons. )} - {rows} + {selectionEnabled && series.length > 0 && ( + + + + + + Series + Value + {hasItemActions && Actions} + + + )} + + {rowsData.map(({ ts, idx, rowData }) => { + const displayTimeStamps = (ts.values?.length ?? 0) > 1; + const valuesAndTimes = ts.values + ? ts.values.map((v, valIdx) => ( + + {v[1]} {displayTimeStamps && @{v[0]}} + + )) + : []; + + let histogramsAndTimes = null; + if (ts.histograms && ts.histograms.length > 0) { + const seriesQueryResult: PanelData = { + ...queryResults[0]!, + data: { + ...queryResults[0]!.data, + series: [queryResults[0]!.data.series[idx]!], + }, + }; + + histogramsAndTimes = ts.histograms.map((h: TimeSeriesHistogramTuple, hisIdx: number) => ( + + + + + + Total count: {h[1].count} + Sum: {h[1].sum} + + {histogramTable(h[1])} + + )); + } + + const rowId = idx.toString(); + const isSelected = selectionMap.has(rowId); + const isFormatted = series.length < MAX_FORMATTABLE_SERIES; + + return ( + + {selectionEnabled && ( + + handleRowSelectionToggle(ts, idx)} + inputProps={{ 'aria-label': `select series ${idx}` }} + /> + + )} + + + + {ts.histograms ? histogramsAndTimes : valuesAndTimes} + {hasItemActions && ( + + {getItemActionButtons({ id: rowId, data: rowData })} + + )} + + ); + })} +
); }; -function buildRows(series: TimeSeries[], queryResults: Array>): ReactNode[] { - const isFormatted = series.length < MAX_FORMATTABLE_SERIES; // only format series names if we have less than 1000 series for performance reasons - return series.map((s, seriesIdx) => { - const displayTimeStamps = (s.values?.length ?? 0) > 1; - const valuesAndTimes = s.values - ? s.values.map((v, valIdx) => { - return ( - - {v[1]} {displayTimeStamps && @{v[0]}} - - ); - }) - : []; - - let histogramsAndTimes = null; - if (s.histograms && s.histograms.length > 0) { - // Query results contains multiple series, create a new query result with only the current series - const seriesQueryResult: PanelData = { - ...queryResults[0]!, - data: { - ...queryResults[0]!.data, - series: [queryResults[0]!.data.series[seriesIdx]!], - }, - }; - - histogramsAndTimes = s.histograms.map((h: TimeSeriesHistogramTuple, hisIdx: number) => { - return ( - - - - - - Total count: {h[1].count} - Sum: {h[1].sum} - - {histogramTable(h[1])} - - ); - }); - } - return ( - - - - - {s.histograms ? histogramsAndTimes : valuesAndTimes} - - ); - }); -} - const leftDelim = (br: number): string => (br === 3 || br === 1 ? '[' : '('); const rightDelim = (br: number): string => (br === 3 || br === 0 ? ']' : ')'); diff --git a/timeseriestable/src/components/TimeSeriesTableItemSelectionActionsEditor.tsx b/timeseriestable/src/components/TimeSeriesTableItemSelectionActionsEditor.tsx new file mode 100644 index 00000000..e8ca51f0 --- /dev/null +++ b/timeseriestable/src/components/TimeSeriesTableItemSelectionActionsEditor.tsx @@ -0,0 +1,38 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ActionOptions, ItemSelectionActionsEditor, SelectionOptions } from '@perses-dev/plugin-system'; +import { ReactElement } from 'react'; +import { TimeSeriesTableSettingsEditorProps } from '../model'; + +export function TimeSeriesTableItemSelectionActionsEditor({ + value, + onChange, +}: TimeSeriesTableSettingsEditorProps): ReactElement { + function handleActionsChange(actions: ActionOptions | undefined): void { + onChange({ ...value, actions: actions }); + } + + function handleSelectionChange(selection: SelectionOptions | undefined): void { + onChange({ ...value, selection: selection }); + } + + return ( + + ); +} diff --git a/timeseriestable/src/components/index.ts b/timeseriestable/src/components/index.ts index 040329c8..5cd92774 100644 --- a/timeseriestable/src/components/index.ts +++ b/timeseriestable/src/components/index.ts @@ -13,3 +13,4 @@ export * from './DataTable'; export * from './SeriesName'; +export * from './TimeSeriesTableItemSelectionActionsEditor'; diff --git a/timeseriestable/src/model.ts b/timeseriestable/src/model.ts index af28a4ca..d102c189 100644 --- a/timeseriestable/src/model.ts +++ b/timeseriestable/src/model.ts @@ -11,5 +11,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface TimeSeriesTableOptions {} +import { ActionOptions, OptionsEditorProps, SelectionOptions } from '@perses-dev/plugin-system'; + +export interface TimeSeriesTableOptions { + /** + * Enable row selection with checkboxes + */ + selection?: SelectionOptions; + /** + * Configure actions that can be executed on selected rows + */ + actions?: ActionOptions; +} + +export type TimeSeriesTableSettingsEditorProps = OptionsEditorProps;