diff --git a/table/src/components/ColumnsEditor/ColumnEditor.tsx b/table/src/components/ColumnsEditor/ColumnEditor.tsx index 022f6a49e..765799c1b 100644 --- a/table/src/components/ColumnsEditor/ColumnEditor.tsx +++ b/table/src/components/ColumnsEditor/ColumnEditor.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Button, ButtonGroup, Stack, StackProps, Switch, TextField } from '@mui/material'; +import { Button, ButtonGroup, Stack, StackProps, Switch, TextField, Typography } from '@mui/material'; import { ReactElement, useState } from 'react'; import { AlignSelector, @@ -28,6 +28,7 @@ import { PluginKindSelect } from '@perses-dev/plugin-system'; import { ColumnSettings } from '../../models'; import { ConditionalPanel } from '../ConditionalPanel'; import { DataLinkEditor } from './DataLinkEditorDialog'; +import { EmbeddedPanelOptionsEditor } from './EmbeddedPanelOptionsEditor'; const DEFAULT_FORMAT: FormatOptions = { unit: 'decimal', @@ -131,30 +132,37 @@ export function ColumnEditor({ column, onChange, ...others }: ColumnEditorProps) } /> - - - + + + + + + + Visualizations reuse panel settings (thresholds, units, colors). Text mode uses value formatting + below. + + } /> {column.plugin ? ( onChange({ ...column, plugin: { kind: event.kind, spec: {} } })} /> @@ -214,7 +222,23 @@ export function ColumnEditor({ column, onChange, ...others }: ColumnEditorProps) - + {column.plugin?.kind === 'GaugeChart' && ( + + + + onChange({ + ...column, + plugin: { kind: 'GaugeChart', spec: nextSpec }, + }) + } + /> + + + )} + void; +} + +function isSpecEmpty(spec: UnknownSpec | undefined): boolean { + if (spec === undefined || spec === null) return true; + if (typeof spec !== 'object') return false; + return Object.keys(spec as object).length === 0; +} + +function mergeWithPluginDefaults(plugin: PanelPlugin, spec: UnknownSpec | undefined): UnknownSpec { + const initial = plugin.createInitialOptions() ?? {}; + return merge({}, initial, spec ?? {}) as UnknownSpec; +} + +/** + * Renders a panel plugin's settings tabs (thresholds, units, colors, …). + * Used for embedded GaugeChart columns only; other embedded panel kinds use defaults. + */ +export function EmbeddedPanelOptionsEditor({ kind, spec, onChange }: EmbeddedPanelOptionsEditorProps): ReactElement { + const { data: plugin, isLoading, isError, error } = usePlugin('Panel', kind); + + const panelPlugin = plugin as PanelPlugin | undefined; + + const mergedSpec = useMemo(() => { + if (!panelPlugin) { + return spec; + } + return mergeWithPluginDefaults(panelPlugin, spec); + }, [panelPlugin, spec]); + + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // Persist plugin defaults when the column still has an empty spec (e.g. after switching panel kind). + useEffect(() => { + if (!panelPlugin || !isSpecEmpty(spec)) { + return; + } + onChangeRef.current(mergeWithPluginDefaults(panelPlugin, spec)); + }, [panelPlugin, kind, spec]); + + if (isLoading) { + return ( + + + + Loading panel settings… + + + ); + } + + if (isError || !plugin) { + return ( + + {error?.message ?? 'Could not load panel plugin.'} + + ); + } + + const loadedPlugin = plugin as PanelPlugin; + const editorTabs = loadedPlugin.panelOptionsEditorComponents ?? []; + + if (editorTabs.length === 0) { + return ( + + This visualization has no editable settings. + + ); + } + + return ( + + { + const Content = tab.content; + return { + label: tab.label, + content: ( + { + onChange(next as UnknownSpec); + }} + /> + ), + }; + })} + /> + + ); +} diff --git a/table/src/components/TablePanel.tsx b/table/src/components/TablePanel.tsx index 2adbdf249..51ffdb5e1 100644 --- a/table/src/components/TablePanel.tsx +++ b/table/src/components/TablePanel.tsx @@ -13,7 +13,7 @@ import { Box, Theme, Typography, useTheme } from '@mui/material'; import { Table, TableCellConfigs, TableColumnConfig, useSelection } from '@perses-dev/components'; -import { formatValue, QueryDataType, TimeSeriesData, transformData } from '@perses-dev/core'; +import { CalculationsMap, formatValue, QueryDataType, TimeSeriesData, transformData } from '@perses-dev/core'; import { useSelectionItemActions } from '@perses-dev/dashboards'; import { ActionOptions, @@ -25,18 +25,158 @@ import { } from '@perses-dev/plugin-system'; import { ColumnFiltersState, PaginationState, RowSelectionState, SortingState } from '@tanstack/react-table'; import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ColumnSettings, evaluateConditionalFormatting, TableOptions } from '../models'; +import { CellSettings, ColumnSettings, evaluateConditionalFormatting, TableOptions } from '../models'; import { buildRawTableData, getTablePanelQueryMode } from '../table-data-utils'; import { EmbeddedPanel } from './EmbeddedPanel'; +function parseNumericCellValue(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +function isPanelData(value: unknown): value is PanelData { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as { definition?: unknown; data?: unknown }; + return candidate.definition !== undefined && candidate.data !== undefined; +} + +function createSyntheticPanelData(value: unknown, columnName: string): PanelData | undefined { + const numericValue = parseNumericCellValue(value); + if (numericValue === undefined) { + return undefined; + } + + const now = Date.now(); + return { + definition: { + kind: 'TimeSeriesQuery', + spec: { plugin: { kind: 'PrometheusTimeSeriesQuery', spec: { query: '' } } }, + }, + data: { + timeRange: { start: new Date(now), end: new Date(now) }, + stepMs: 1, + series: [{ name: columnName, values: [[now, numericValue]], labels: {} }], + }, + }; +} + +function getGaugeNumericValue(value: unknown): number | undefined { + if (isPanelData(value)) { + const series = (value.data as TimeSeriesData)?.series; + const firstSeries = series?.[0]; + if (!firstSeries?.values?.length) { + return undefined; + } + const calc = CalculationsMap['last-number']; + if (typeof calc !== 'function') { + return undefined; + } + const calculatedValue = calc(firstSeries.values); + return typeof calculatedValue === 'number' ? calculatedValue : undefined; + } + + return parseNumericCellValue(value); +} + +interface GaugeRange { + min: number; + max: number; +} + +function InlineGaugeCellWithRange({ + value, + range, + fillColor, + format, +}: { + value?: number; + range?: GaugeRange; + fillColor?: string; + format?: ColumnSettings['format']; +}): ReactElement { + if (value === undefined) { + return <>; + } + + let percent = 0; + if (range !== undefined) { + if (range.max === range.min) { + percent = 100; + } else { + percent = ((value - range.min) / (range.max - range.min)) * 100; + } + } + percent = Math.max(0, Math.min(100, percent)); + + const trackColor = 'rgba(127,127,127,0.20)'; + + return ( + + + + + + {format ? formatValue(value, format) : value.toFixed(2)} + + + ); +} + +function resolveGaugeFillColor( + value: unknown, + globalCellSettings: CellSettings[], + columnCellSettings: CellSettings[] | undefined +): string | undefined { + let cellConfig = evaluateConditionalFormatting(value, globalCellSettings); + if (columnCellSettings?.length) { + const columnCellConfig = evaluateConditionalFormatting(value, columnCellSettings); + if (columnCellConfig) { + cellConfig = columnCellConfig; + } + } + return cellConfig?.backgroundColor ?? cellConfig?.textColor; +} + function generateCellContentConfig( - column: ColumnSettings + column: ColumnSettings, + gaugeRange?: GaugeRange, + globalCellSettings: CellSettings[] = [] ): Pick, 'cellDescription' | 'cell'> { const plugin = column.plugin; if (plugin !== undefined) { return { cell: (ctx): ReactElement => { - const panelData: PanelData | undefined = ctx.getValue(); + const cellValue = ctx.getValue(); + if (plugin.kind === 'GaugeChart') { + const gaugeValue = getGaugeNumericValue(cellValue); + const gaugeFillColor = resolveGaugeFillColor(gaugeValue, globalCellSettings, column.cellSettings); + return ( + + ); + } + const panelData = isPanelData(cellValue) ? cellValue : createSyntheticPanelData(cellValue, column.name); if (!panelData) return <>; return ; }, @@ -190,7 +330,9 @@ function ColumnFilterDropdown({ function generateColumnConfig( name: string, columnSettings: ColumnSettings[], - allVariables: VariableStateMap + allVariables: VariableStateMap, + gaugeRangeByColumn: Record, + globalCellSettings: CellSettings[] = [] ): TableColumnConfig | undefined { for (const column of columnSettings) { if (column.name === name) { @@ -211,7 +353,7 @@ function generateColumnConfig( width, align, dataLink: modifiedDataLink, - ...generateCellContentConfig(column), + ...generateCellContentConfig(column, gaugeRangeByColumn[name], globalCellSettings), }; } } @@ -314,6 +456,30 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps return uniqueValues; }, [data, keys]); + const gaugeRangeByColumn = useMemo(() => { + const result: Record = {}; + + for (const key of keys) { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const row of data) { + const numericValue = getGaugeNumericValue(row[key]); + if (numericValue === undefined) { + continue; + } + min = Math.min(min, numericValue); + max = Math.max(max, numericValue); + } + + if (min !== Number.POSITIVE_INFINITY && max !== Number.NEGATIVE_INFINITY) { + result[key] = { min, max }; + } + } + + return result; + }, [data, keys]); + // Generate columns and map each column accessor to its settings index and data key const columns: Array> = useMemo(() => { const columns: Array> = []; @@ -323,7 +489,13 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps for (const columnSetting of spec.columnSettings ?? []) { if (customizedColumns.has(columnSetting.name)) continue; // Skip duplicates - const columnConfig = generateColumnConfig(columnSetting.name, spec.columnSettings ?? [], allVariables); + const columnConfig = generateColumnConfig( + columnSetting.name, + spec.columnSettings ?? [], + allVariables, + gaugeRangeByColumn, + spec.cellSettings ?? [] + ); if (columnConfig !== undefined) { columns.push(columnConfig); customizedColumns.add(columnSetting.name); @@ -334,7 +506,13 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps if (!spec.defaultColumnHidden) { for (const key of keys) { if (!customizedColumns.has(key)) { - const columnConfig = generateColumnConfig(key, spec.columnSettings ?? [], allVariables); + const columnConfig = generateColumnConfig( + key, + spec.columnSettings ?? [], + allVariables, + gaugeRangeByColumn, + spec.cellSettings ?? [] + ); if (columnConfig !== undefined) { columns.push(columnConfig); } @@ -343,7 +521,7 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps } return columns; - }, [keys, spec.columnSettings, spec.defaultColumnHidden, allVariables]); + }, [keys, spec.columnSettings, spec.defaultColumnHidden, allVariables, gaugeRangeByColumn, spec.cellSettings]); // Generate cell settings that will be used by the table to render cells (text color, background color, ...) const cellConfigs: TableCellConfigs = useMemo(() => { diff --git a/table/src/models/table-model.ts b/table/src/models/table-model.ts index 6c610ca87..54ff238d5 100644 --- a/table/src/models/table-model.ts +++ b/table/src/models/table-model.ts @@ -162,6 +162,16 @@ export function createInitialTableOptions(): TableOptions { export type TableSettingsEditorProps = OptionsEditorProps; +function parseRangeBound(rawValue: string): number | undefined { + const trimmed = rawValue.trim(); + if (trimmed === '') { + return undefined; + } + + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; +} + /** * Formats the display text and colors based on cell settings */ @@ -293,8 +303,12 @@ export function renderConditionEditor( label: 'From', placeholder: 'Start of range', value: condition.spec?.min ?? '', - onChange: (e: { target: { value: string } }) => - onChange({ ...condition, spec: { ...condition.spec, min: +e.target.value } } as RangeCondition), + onChange: (e: { target: { value: string } }) => { + const nextMin = parseRangeBound(e.target.value); + onChange({ ...condition, spec: { ...condition.spec, min: nextMin } } as RangeCondition); + }, + type: 'number', + slotProps: { htmlInput: { step: 'any' } }, fullWidth: true, size: size, }), @@ -303,8 +317,12 @@ export function renderConditionEditor( label: 'To', placeholder: 'End of range (inclusive)', value: condition.spec?.max ?? '', - onChange: (e: { target: { value: string } }) => - onChange({ ...condition, spec: { ...condition.spec, max: +e.target.value } } as RangeCondition), + onChange: (e: { target: { value: string } }) => { + const nextMax = parseRangeBound(e.target.value); + onChange({ ...condition, spec: { ...condition.spec, max: nextMax } } as RangeCondition); + }, + type: 'number', + slotProps: { htmlInput: { step: 'any' } }, fullWidth: true, size: size, }),