From 082030adc2c2c45f13ec225aee2d32afce36d8fa Mon Sep 17 00:00:00 2001 From: Praveen K B Date: Fri, 15 May 2026 11:13:01 +0530 Subject: [PATCH] feat: paginated value suggestions via dataset_stats with top-5 + show more --- src/components/FilterBuilder.tsx | 138 ++++++++++++++++++++++++++++--- src/datasource.ts | 35 ++++---- 2 files changed, 146 insertions(+), 27 deletions(-) diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 468bb87..8d1b3cb 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -1,11 +1,15 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Select, AsyncSelect, Button, IconButton, useStyles2 } from '@grafana/ui'; +import { Select, Button, IconButton, useStyles2, useTheme2, getSelectStyles } from '@grafana/ui'; import { FilterCondition } from '../types'; import { FieldTypeMap, getOperators, typeDisplayName } from '../utils/fieldTypes'; import { isNullOperator } from '../utils/queryBuilder'; import { DataSource } from '../datasource'; +import { HumanizeNumber } from '../utils/format'; + +const INITIAL_PAGE = 5; +const NEXT_PAGE = 5000; interface FilterBuilderProps { filters: FilterCondition[]; @@ -84,23 +88,111 @@ export const FilterBuilder: React.FC = ({ resetAddForm(); }, [newColumn, newOperator, newValue, filters, onChange, resetAddForm, fieldTypeMap]); - const loadValueSuggestions = useCallback( - async (inputValue: string): Promise>> => { - if (!streamName || !newColumn?.value) { + const [valueOptions, setValueOptions] = useState>([]); + const [valueOffset, setValueOffset] = useState(0); + const [valueHasMore, setValueHasMore] = useState(false); + const [valueLoading, setValueLoading] = useState(false); + const requestIdRef = useRef(0); + + const fetchValues = useCallback( + async (column: string, offset: number, limit: number) => { + if (!streamName) { return []; } try { - const values = await datasource.getDistinctValues(streamName, newColumn.value); - return values - .filter((v) => !inputValue || v.toLowerCase().includes(inputValue.toLowerCase())) - .map((v) => ({ label: v, value: v })); + return await datasource.getDistinctValues(streamName, column, { offset, limit }); } catch { return []; } }, - [streamName, newColumn, datasource] + [streamName, datasource] ); + useEffect(() => { + const column = newColumn?.value; + if (!column || !streamName) { + setValueOptions([]); + setValueOffset(0); + setValueHasMore(false); + return; + } + const reqId = ++requestIdRef.current; + setValueLoading(true); + setValueOptions([]); + setValueOffset(0); + setValueHasMore(false); + fetchValues(column, 0, INITIAL_PAGE).then((rows) => { + if (reqId !== requestIdRef.current) { + return; + } + setValueOptions(rows); + setValueOffset(rows.length); + setValueHasMore(rows.length === INITIAL_PAGE); + setValueLoading(false); + }); + }, [newColumn, streamName, fetchValues]); + + const loadMoreValues = useCallback(async () => { + const column = newColumn?.value; + if (!column || valueLoading || !valueHasMore) { + return; + } + const reqId = ++requestIdRef.current; + setValueLoading(true); + const rows = await fetchValues(column, valueOffset, NEXT_PAGE); + if (reqId !== requestIdRef.current) { + return; + } + setValueOptions((prev) => [...prev, ...rows]); + setValueOffset((prev) => prev + rows.length); + setValueHasMore(rows.length === NEXT_PAGE); + setValueLoading(false); + }, [newColumn, fetchValues, valueOffset, valueHasMore, valueLoading]); + + const valueSelectOptions: Array> = useMemo( + () => + valueOptions.map((row) => ({ + label: row.value, + value: row.value, + description: HumanizeNumber(row.count), + })), + [valueOptions] + ); + + const theme = useTheme2(); + const grafanaSelectStyles = getSelectStyles(theme); + + const MenuListWithMore = useMemo(() => { + const styles2 = styles; + const menuClass = grafanaSelectStyles.menu; + const Comp: React.FC = (menuProps) => { + const { innerRef, innerProps, maxHeight } = menuProps; + return ( +
+
+ {menuProps.children} + {(valueHasMore || valueLoading) && ( +
{ + e.preventDefault(); + e.stopPropagation(); + if (!valueLoading) { + loadMoreValues(); + } + }} + > + {valueLoading ? 'Loading…' : 'Show more values'} +
+ )} +
+
+ ); + }; + return Comp; + }, [valueHasMore, valueLoading, loadMoreValues, styles, grafanaSelectStyles.menu]); + const formatFilterDisplay = (filter: FilterCondition): string => { if (isNullOperator(filter.operator)) { return `${filter.column} ${filter.operator}`; @@ -163,12 +255,13 @@ export const FilterBuilder: React.FC = ({ disabled={!newColumn?.value} /> {newOperator && !isNullOperator(newOperator.value!) && ( - setNewValue(v?.value || '')} + components={{ MenuList: MenuListWithMore }} + isLoading={valueLoading} allowCustomValue onCreateOption={(v) => setNewValue(v)} placeholder="Value" @@ -229,4 +322,23 @@ const getStyles = (theme: GrafanaTheme2) => ({ alignItems: 'center', flexWrap: 'wrap', }), + menuScroll: css({ + overflowY: 'auto', + overflowX: 'hidden', + padding: theme.spacing(0.5), + }), + showMore: css({ + marginTop: theme.spacing(0.5), + padding: `${theme.spacing(0.75)} ${theme.spacing(1.25)}`, + borderTop: `1px solid ${theme.colors.border.weak}`, + color: theme.colors.text.link, + cursor: 'pointer', + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + userSelect: 'none', + textAlign: 'center', + [`&:hover`]: { + background: theme.colors.action.hover, + }, + }), }); diff --git a/src/datasource.ts b/src/datasource.ts index fcbb795..c191ea8 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -859,34 +859,41 @@ export class DataSource extends DataSourceWithBackend { - const query = `SELECT DISTINCT "${columnName}" FROM ${streamName} LIMIT ${limit}`; + async getDistinctValues( + streamName: string, + columnName: string, + opts: { limit?: number; offset?: number } = {} + ): Promise> { + const limit = opts.limit ?? 5; + const offset = opts.offset ?? 0; const now = new Date(); const from = new Date(); from.setDate(now.getDate() - 7); try { return await lastValueFrom( - this.doFetch({ - url: this.url + '/api/v1/query', + this.doFetch }>>({ + url: this.url + '/api/prism/v1/dataset_stats', data: { - query, + datasetName: streamName, startTime: from.toISOString(), endTime: now.toISOString(), - send_null: true, + fields: [columnName], + offset, + limit, }, method: 'POST', }).pipe( map((res) => { - if (isArray(res.data)) { - return res.data - .map((row) => { - const val = row[columnName]; - return val != null ? String(val) : ''; - }) - .filter(Boolean); + const fieldStats = res.data?.[columnName]; + const distinct = fieldStats?.distinct_values; + if (!distinct || typeof distinct !== 'object') { + return []; } - return []; + return Object.entries(distinct) + .map(([value, count]) => ({ value: String(value), count: Number(count) || 0 })) + .filter((row) => row.value) + .sort((a, b) => b.count - a.count); }), catchError(() => of([])) )