From 3a32a73459ab10c7e36099cad2406e6ad8dfda4a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 11 May 2022 08:32:13 -0700 Subject: [PATCH] Search: use bluge index for frontend search (playground) (#48847) --- package.json | 3 +- .../app/features/search/page/SearchPage.tsx | 205 +++++------ .../search/page/components/ActionRow.tsx | 10 +- .../page/components/ConfirmDeleteModal.tsx | 2 +- .../search/page/components/FolderSection.tsx | 177 ++++++++++ .../search/page/components/FolderView.tsx | 127 +++++++ .../page/components/SearchResultsGrid.tsx | 104 ++++++ .../page/components/SearchResultsTable.tsx | 120 +++---- .../search/page/components/columns.tsx | 249 ++++++-------- public/app/features/search/service/backend.ts | 213 ------------ public/app/features/search/service/bluge.ts | 133 ++++++++ .../features/search/service/minisearcher.ts | 320 ------------------ .../features/search/service/searcher.test.ts | 56 --- .../app/features/search/service/searcher.ts | 4 +- public/app/features/search/service/types.ts | 61 +++- .../grafana/components/QueryEditor.tsx | 76 ++++- .../app/plugins/datasource/grafana/types.ts | 2 +- yarn.lock | 32 +- 18 files changed, 918 insertions(+), 976 deletions(-) create mode 100644 public/app/features/search/page/components/FolderSection.tsx create mode 100644 public/app/features/search/page/components/FolderView.tsx create mode 100644 public/app/features/search/page/components/SearchResultsGrid.tsx delete mode 100644 public/app/features/search/service/backend.ts create mode 100644 public/app/features/search/service/bluge.ts delete mode 100644 public/app/features/search/service/minisearcher.ts delete mode 100644 public/app/features/search/service/searcher.test.ts diff --git a/package.json b/package.json index 428cbca42a9..a9d18adebe7 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "@types/react-transition-group": "4.4.4", "@types/react-virtualized-auto-sizer": "1.0.1", "@types/react-window": "1.8.5", + "@types/react-window-infinite-loader": "^1", "@types/redux-mock-store": "1.0.3", "@types/reselect": "2.2.0", "@types/semver": "7.3.9", @@ -327,7 +328,6 @@ "logfmt": "^1.3.2", "lru-cache": "7.9.0", "memoize-one": "6.0.0", - "minisearch": "5.0.0-beta1", "moment": "2.29.2", "moment-timezone": "0.5.34", "monaco-editor": "^0.31.1", @@ -369,6 +369,7 @@ "react-use": "17.3.2", "react-virtualized-auto-sizer": "1.0.6", "react-window": "1.8.6", + "react-window-infinite-loader": "^1.0.7", "redux": "4.1.2", "redux-thunk": "2.4.1", "regenerator-runtime": "0.13.9", diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx index 55fb1a3a11b..72b75fd6497 100644 --- a/public/app/features/search/page/SearchPage.tsx +++ b/public/app/features/search/page/SearchPage.tsx @@ -1,25 +1,24 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; -import { useAsync } from 'react-use'; +import { useAsync, useDebounce } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeGrid } from 'react-window'; -import { DataFrameView, GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField, Button } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { PreviewsSystemRequirements } from '../components/PreviewsSystemRequirements'; -import { SearchCard } from '../components/SearchCard'; import { useSearchQuery } from '../hooks/useSearchQuery'; -import { getGrafanaSearcher, QueryFilters, QueryResult } from '../service'; -import { getTermCounts } from '../service/backend'; -import { DashboardSearchItemType, DashboardSectionItem, SearchLayout } from '../types'; +import { getGrafanaSearcher, SearchQuery } from '../service'; +import { SearchLayout } from '../types'; import { ActionRow, getValidQueryLayout } from './components/ActionRow'; +import { FolderView } from './components/FolderView'; import { ManageActions } from './components/ManageActions'; -import { SearchResultsTable } from './components/SearchResultsTable'; +import { SearchResultsGrid } from './components/SearchResultsGrid'; +import { SearchResultsTable, SearchResultsProps } from './components/SearchResultsTable'; import { newSearchSelection, updateSearchSelection } from './selection'; const node: NavModelItem = { @@ -38,16 +37,30 @@ export default function SearchPage() { const [showManage, setShowManage] = useState(false); // grid vs list view const [searchSelection, setSearchSelection] = useState(newSearchSelection()); + const layout = getValidQueryLayout(query); + const isFolders = layout === SearchLayout.Folders; const results = useAsync(() => { - const { query: searchQuery, tag: tags, datasource } = query; - - const filters: QueryFilters = { - tags, - datasource, + let qstr = query.query as string; + if (!qstr?.length) { + qstr = '*'; + } + const q: SearchQuery = { + query: qstr, + tags: query.tag as string[], + ds_uid: query.datasource as string, }; - return getGrafanaSearcher().search(searchQuery, tags.length || datasource ? filters : undefined); - }, [query]); + console.log('DO QUERY', q); + return getGrafanaSearcher().search(q); + }, [query, layout]); + + const [inputValue, setInputValue] = useState(''); + const onSearchQueryChange = (e: React.ChangeEvent) => { + e.preventDefault(); + setInputValue(e.currentTarget.value); + }; + + useDebounce(() => onQueryChange(inputValue), 200, [inputValue]); if (!config.featureToggles.panelTitleSearch) { return
Unsupported
; @@ -55,20 +68,12 @@ export default function SearchPage() { // This gets the possible tags from within the query results const getTagOptions = (): Promise => { - const tags = results.value?.body.fields.find((f) => f.name === 'tags'); - - if (tags) { - return Promise.resolve(getTermCounts(tags)); - } - return Promise.resolve([]); - }; - - const onSearchQueryChange = (event: React.ChangeEvent) => { - onQueryChange(event.currentTarget.value); - }; - - const onTagChange = (tags: string[]) => { - onTagFilterChange(tags); + const q: SearchQuery = { + query: query.query ?? '*', + tags: query.tag, + ds_uid: query.datasource, + }; + return getGrafanaSearcher().tags(q); }; const onTagSelected = (tag: string) => { @@ -83,16 +88,14 @@ export default function SearchPage() { setSearchSelection(updateSearchSelection(searchSelection, !current, kind, [uid])); }; - const layout = getValidQueryLayout(query); - const showPreviews = layout === SearchLayout.Grid && config.featureToggles.dashboardPreviews; - const renderResults = () => { - if (results.loading) { - return ; - } + const value = results.value; + + if ((!value || !value.totalRows) && !isFolders) { + if (results.loading && !value) { + return ; + } - const df = results.value?.body; - if (!df || !df.length) { return (
No results found for your query.
@@ -117,103 +120,58 @@ export default function SearchPage() { ); } - return ( - - {({ width, height }) => { - if (showPreviews) { - const view = new DataFrameView(df); + const selection = showManage ? searchSelection.isSelected : undefined; + if (layout === SearchLayout.Folders) { + return ; + } - // Hacked to reuse existing SearchCard (and old DashboardSectionItem) - const itemProps = { - editable: showManage, - onToggleChecked: (item: any) => { - const d = item as DashboardSectionItem; - const t = d.type === DashboardSearchItemType.DashFolder ? 'folder' : 'dashboard'; - toggleSelection(t, d.uid!); - }, - onTagSelected, + return ( +
+ + {({ width, height }) => { + const props: SearchResultsProps = { + response: value!, + selection, + selectionToggle: toggleSelection, + width: width, + height: height, + onTagSelected: onTagSelected, + onDatasourceChange: query.datasource ? onDatasourceChange : undefined, }; - const numColumns = Math.ceil(width / 320); - const cellWidth = width / numColumns; - const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8; - const numRows = Math.ceil(df.length / numColumns); - return ( - - {({ columnIndex, rowIndex, style }) => { - const index = rowIndex * numColumns + columnIndex; - const item = view.get(index); - const kind = item.kind ?? 'dashboard'; - const facade: DashboardSectionItem = { - uid: item.uid, - title: item.name, - url: item.url, - uri: item.url, - type: kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, - id: 666, // do not use me! - isStarred: false, - tags: item.tags ?? [], - checked: searchSelection.isSelected(kind, item.uid), - }; + if (layout === SearchLayout.Grid) { + return ; + } - // The wrapper div is needed as the inner SearchItem has margin-bottom spacing - // And without this wrapper there is no room for that margin - return item ? ( -
  • - -
  • - ) : null; - }} -
    - ); - } - - return ( - <> - - - ); - }} -
    + return ; + }} + +
    ); }; return ( - + : null} /> setShowManage(!showManage)} /> -
    -
    {Boolean(searchSelection.items.size > 0) ? ( @@ -235,14 +193,13 @@ export default function SearchPage() { /> )} - {showPreviews && ( + {layout === SearchLayout.Grid && ( onLayoutChange(SearchLayout.List)} /> )} - {renderResults()}
    @@ -250,6 +207,9 @@ export default function SearchPage() { } const getStyles = (theme: GrafanaTheme2) => ({ + searchInput: css` + margin-bottom: 6px; + `, unsupported: css` padding: 10px; display: flex; @@ -258,17 +218,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ height: 100%; font-size: 18px; `, - virtualizedGridItemWrapper: css` - padding: 4px; - `, - wrapper: css` - display: flex; - flex-direction: column; - - > ul { - list-style: none; - } - `, noResults: css` padding: ${theme.v1.spacing.md}; background: ${theme.v1.colors.bg2}; diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index d79af608095..92a7cc63e6c 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -31,13 +31,19 @@ interface Props { } export function getValidQueryLayout(q: DashboardQuery): SearchLayout { + const layout = q.layout ?? SearchLayout.Folders; + // Folders is not valid when a query exists - if (q.layout === SearchLayout.Folders) { + if (layout === SearchLayout.Folders) { if (q.query || q.sort) { return SearchLayout.List; } } - return q.layout; + + if (layout === SearchLayout.Grid && !config.featureToggles.dashboardPreviews) { + return SearchLayout.List; + } + return layout; } export const ActionRow: FC = ({ diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.tsx index 8058f641fbc..c1269a93fdf 100644 --- a/public/app/features/search/page/components/ConfirmDeleteModal.tsx +++ b/public/app/features/search/page/components/ConfirmDeleteModal.tsx @@ -19,7 +19,7 @@ export const ConfirmDeleteModal: FC = ({ results, onDeleteItems, isOpen, const styles = getStyles(theme); const dashboards = Array.from(results.get('dashboard') ?? []); - const folders = Array.from(results.get('folders') ?? []); + const folders = Array.from(results.get('folder') ?? []); const folderCount = folders.length; const dashCount = dashboards.length; diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx new file mode 100644 index 00000000000..263cb7f320d --- /dev/null +++ b/public/app/features/search/page/components/FolderSection.tsx @@ -0,0 +1,177 @@ +import { css, cx } from '@emotion/css'; +import React, { FC } from 'react'; +import { useAsync, useLocalStorage } from 'react-use'; + +import { GrafanaTheme } from '@grafana/data'; +import { Checkbox, CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui'; +import { getSectionStorageKey } from 'app/features/search/utils'; +import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId'; + +import { SearchItem } from '../..'; +import { getGrafanaSearcher } from '../../service'; +import { DashboardSearchItemType, DashboardSectionItem } from '../../types'; +import { SelectionChecker, SelectionToggle } from '../selection'; + +export interface DashboardSection { + kind: string; // folder | query! + uid: string; + title: string; + selected?: boolean; // not used ? keyboard + url?: string; + icon?: string; +} + +interface SectionHeaderProps { + selection?: SelectionChecker; + selectionToggle?: SelectionToggle; + onTagSelected: (tag: string) => void; + section: DashboardSection; +} + +export const FolderSection: FC = ({ section, selectionToggle, onTagSelected, selection }) => { + const editable = selectionToggle != null; + const theme = useTheme(); + const styles = getSectionHeaderStyles(theme, section.selected, editable); + const [sectionExpanded, setSectionExpanded] = useLocalStorage(getSectionStorageKey(section.title), false); + + const results = useAsync(async () => { + if (!sectionExpanded) { + return Promise.resolve([] as DashboardSectionItem[]); + } + let query = { + query: '*', + kind: ['dashboard'], + location: section.uid, + }; + if (section.title === 'Starred') { + // TODO + } else if (section.title === 'Recent') { + // TODO + } + const raw = await getGrafanaSearcher().search(query); + const v = raw.view.map( + (item) => + ({ + uid: item.uid, + title: item.name, + url: item.url, + uri: item.url, + type: item.kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, + id: 666, // do not use me! + isStarred: false, + tags: item.tags ?? [], + checked: selection ? selection(item.kind, item.uid) : false, + } as DashboardSectionItem) + ); + console.log('HERE!'); + return v; + }, [sectionExpanded, section]); + + const onSectionExpand = () => { + setSectionExpanded(!sectionExpanded); + console.log('TODO!! section', section.title, section); + }; + + const id = useUniqueId(); + const labelId = `section-header-label-${id}`; + + let icon = section.icon; + if (!icon) { + icon = sectionExpanded ? 'folder-open' : 'folder'; + } + + return ( + + {selectionToggle && selection && ( +
    console.log(v)} className={styles.checkbox}> + +
    + )} + +
    + +
    + +
    + {section.title} + {section.url && ( + + | Go to folder + + )} +
    + + } + > + {results.value && ( +
      + {results.value.map((v) => ( + + ))} +
    + )} +
    + ); +}; + +const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false, editable: boolean) => { + const { sm } = theme.spacing; + return { + wrapper: cx( + css` + align-items: center; + font-size: ${theme.typography.size.base}; + padding: 12px; + border-bottom: none; + color: ${theme.colors.textWeak}; + z-index: 1; + + &:hover, + &.selected { + color: ${theme.colors.text}; + } + + &:hover, + &:focus-visible, + &:focus-within { + a { + opacity: 1; + } + } + `, + 'pointer', + { selected } + ), + checkbox: css` + padding: 0 ${sm} 0 0; + `, + icon: css` + padding: 0 ${sm} 0 ${editable ? 0 : sm}; + `, + text: css` + flex-grow: 1; + line-height: 24px; + `, + link: css` + padding: 2px 10px 0; + color: ${theme.colors.textWeak}; + opacity: 0; + transition: opacity 150ms ease-in-out; + `, + separator: css` + margin-right: 6px; + `, + content: css` + padding-top: 0px; + padding-bottom: 0px; + `, + }; +}); diff --git a/public/app/features/search/page/components/FolderView.tsx b/public/app/features/search/page/components/FolderView.tsx new file mode 100644 index 00000000000..7b950428054 --- /dev/null +++ b/public/app/features/search/page/components/FolderView.tsx @@ -0,0 +1,127 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useAsync } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Spinner, useStyles2 } from '@grafana/ui'; + +import { getGrafanaSearcher } from '../../service'; +import { SearchResultsProps } from '../components/SearchResultsTable'; + +import { DashboardSection, FolderSection } from './FolderSection'; + +export const FolderView = ({ + selection, + selectionToggle, + onTagSelected, +}: Pick) => { + const styles = useStyles2(getStyles); + + const results = useAsync(async () => { + const rsp = await getGrafanaSearcher().search({ + query: '*', + kind: ['folder'], + }); + const folders: DashboardSection[] = [ + { title: 'Recent', icon: 'clock', kind: 'query-recent', uid: '__recent' }, + { title: 'Starred', icon: 'star', kind: 'query-star', uid: '__starred' }, + { title: 'General', url: '/dashboards', kind: 'folder', uid: 'general' }, // not sure why this is not in the index + ]; + for (const row of rsp.view) { + folders.push({ + title: row.name, + url: row.url, + uid: row.uid, + kind: row.kind, + }); + } + + return folders; + }, []); + + if (results.loading) { + return ; + } + if (!results.value) { + return
    ?
    ; + } + + return ( +
    + {results.value.map((section) => { + return ( +
    + {section.title && ( + + )} +
    + ); + })} +
    + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + const { md, sm } = theme.v1.spacing; + + return { + virtualizedGridItemWrapper: css` + padding: 4px; + `, + wrapper: css` + display: flex; + flex-direction: column; + + > ul { + list-style: none; + } + `, + section: css` + display: flex; + flex-direction: column; + background: ${theme.v1.colors.panelBg}; + border-bottom: solid 1px ${theme.v1.colors.border2}; + `, + sectionItems: css` + margin: 0 24px 0 32px; + `, + spinner: css` + display: flex; + justify-content: center; + align-items: center; + min-height: 100px; + `, + gridContainer: css` + display: grid; + gap: ${sm}; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + margin-bottom: ${md}; + `, + resultsContainer: css` + position: relative; + flex-grow: 10; + margin-bottom: ${md}; + background: ${theme.v1.colors.bg1}; + border: 1px solid ${theme.v1.colors.border1}; + border-radius: 3px; + height: 100%; + `, + noResults: css` + padding: ${md}; + background: ${theme.v1.colors.bg2}; + font-style: italic; + margin-top: ${theme.v1.spacing.md}; + `, + listModeWrapper: css` + position: relative; + height: 100%; + padding: ${md}; + `, + }; +}; diff --git a/public/app/features/search/page/components/SearchResultsGrid.tsx b/public/app/features/search/page/components/SearchResultsGrid.tsx new file mode 100644 index 00000000000..296e105ba35 --- /dev/null +++ b/public/app/features/search/page/components/SearchResultsGrid.tsx @@ -0,0 +1,104 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { FixedSizeGrid } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { SearchCard } from '../../components/SearchCard'; +import { DashboardSearchItemType, DashboardSectionItem } from '../../types'; + +import { SearchResultsProps } from './SearchResultsTable'; + +export const SearchResultsGrid = ({ + response, + width, + height, + selection, + selectionToggle, + onTagSelected, + onDatasourceChange, +}: SearchResultsProps) => { + const styles = useStyles2(getStyles); + + // Hacked to reuse existing SearchCard (and old DashboardSectionItem) + const itemProps = { + editable: selection != null, + onToggleChecked: (item: any) => { + const d = item as DashboardSectionItem; + const t = d.type === DashboardSearchItemType.DashFolder ? 'folder' : 'dashboard'; + if (selectionToggle) { + selectionToggle(t, d.uid!); + } + }, + onTagSelected, + }; + + const itemCount = response.totalRows ?? response.view.length; + + const view = response.view; + const numColumns = Math.ceil(width / 320); + const cellWidth = width / numColumns; + const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8; + const numRows = Math.ceil(itemCount / numColumns); + return ( + + {({ onItemsRendered, ref }) => ( + + {({ columnIndex, rowIndex, style }) => { + const index = rowIndex * numColumns + columnIndex; + if (index >= view.length) { + return null; + } + + const item = view.get(index); + const kind = item.kind ?? 'dashboard'; + const facade: DashboardSectionItem = { + uid: item.uid, + title: item.name, + url: item.url, + uri: item.url, + type: kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, + id: 666, // do not use me! + isStarred: false, + tags: item.tags ?? [], + checked: selection ? selection(kind, item.uid) : false, + }; + + // The wrapper div is needed as the inner SearchItem has margin-bottom spacing + // And without this wrapper there is no room for that margin + return item ? ( +
  • + +
  • + ) : null; + }} +
    + )} +
    + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + virtualizedGridItemWrapper: css` + padding: 4px; + `, + wrapper: css` + display: flex; + flex-direction: column; + + > ul { + list-style: none; + } + `, +}); diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx index 4b0733778d4..ce766448035 100644 --- a/public/app/features/search/page/components/SearchResultsTable.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.tsx @@ -3,92 +3,61 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table'; import { FixedSizeList } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; -import { DataFrame, DataFrameView, DataSourceRef, Field, GrafanaTheme2 } from '@grafana/data'; +import { Field, GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { TableCell } from '@grafana/ui/src/components/Table/TableCell'; import { getTableStyles } from '@grafana/ui/src/components/Table/styles'; -import { LocationInfo } from '../../service'; -import { SearchLayout } from '../../types'; +import { QueryResponse } from '../../service'; import { SelectionChecker, SelectionToggle } from '../selection'; import { generateColumns } from './columns'; -type Props = { - data: DataFrame; +export type SearchResultsProps = { + response: QueryResponse; width: number; height: number; selection?: SelectionChecker; selectionToggle?: SelectionToggle; - layout: SearchLayout; - tags: string[]; - onTagFilterChange: (tags: string[]) => void; - onDatasourceChange: (datasource?: string) => void; + onTagSelected: (tag: string) => void; + onDatasourceChange?: (datasource?: string) => void; }; export type TableColumn = Column & { field?: Field; }; -export interface FieldAccess { - uid: string; // the item UID - kind: string; // panel, dashboard, folder - name: string; - description: string; - url: string; // link to value (unique) - type: string; // graph - tags: string[]; - location: LocationInfo[]; // the folder name - score: number; - - // Count info - panelCount: number; - datasource: DataSourceRef[]; -} - -const skipHREF = new Set(['column-checkbox', 'column-datasource']); +const skipHREF = new Set(['column-checkbox', 'column-datasource', 'column-location']); +const HEADER_HEIGHT = 36; // pixels export const SearchResultsTable = ({ - data, + response, width, height, - tags, selection, selectionToggle, - layout, - onTagFilterChange, + onTagSelected, onDatasourceChange, -}: Props) => { +}: SearchResultsProps) => { const styles = useStyles2(getStyles); const tableStyles = useStyles2(getTableStyles); const memoizedData = useMemo(() => { - if (!data.fields.length) { + if (!response?.view?.dataFrame.fields.length) { return []; } // as we only use this to fake the length of our data set for react-table we need to make sure we always return an array // filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in // https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585 - return Array(data.length).fill(0); - }, [data]); + return Array(response.totalRows).fill(0); + }, [response]); // React-table column definitions - const access = useMemo(() => new DataFrameView(data), [data]); const memoizedColumns = useMemo(() => { - const isDashboardList = layout === SearchLayout.Folders; - return generateColumns( - access, - isDashboardList, - width, - selection, - selectionToggle, - styles, - tags, - onTagFilterChange, - onDatasourceChange - ); - }, [layout, access, width, styles, tags, selection, selectionToggle, onTagFilterChange, onDatasourceChange]); + return generateColumns(response, width, selection, selectionToggle, styles, onTagSelected, onDatasourceChange); + }, [response, width, styles, selection, selectionToggle, onTagSelected, onDatasourceChange]); const options: TableOptions<{}> = useMemo( () => ({ @@ -105,8 +74,7 @@ export const SearchResultsTable = ({ const row = rows[rowIndex]; prepareRow(row); - const url = access.fields.url?.values.get(rowIndex); - + const url = response.view.fields.url?.values.get(rowIndex); return (
    {row.cells.map((cell: Cell, index: number) => { @@ -122,7 +90,6 @@ export const SearchResultsTable = ({ if (skipHREF.has(cell.column.id)) { return body; } - return ( {body} @@ -132,11 +99,15 @@ export const SearchResultsTable = ({
    ); }, - [rows, prepareRow, access.fields.url?.values, styles.rowContainer, styles.cellWrapper, tableStyles] + [rows, prepareRow, response.view.fields.url?.values, styles.rowContainer, styles.cellWrapper, tableStyles] ); + if (!rows.length) { + return
    No data
    ; + } + return ( -
    +
    {headerGroups.map((headerGroup) => { const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); @@ -157,19 +128,25 @@ export const SearchResultsTable = ({
    - {rows.length > 0 ? ( - - {RenderRow} - - ) : ( -
    No data
    - )} + + {({ onItemsRendered, ref }) => ( + + {RenderRow} + + )} +
    ); @@ -189,9 +166,6 @@ const getStyles = (theme: GrafanaTheme2) => { table: css` width: 100%; `, - tableBody: css` - overflow: 'hidden auto'; - `, cellIcon: css` display: flex; align-items: center; @@ -210,7 +184,7 @@ const getStyles = (theme: GrafanaTheme2) => { `, headerRow: css` background-color: ${theme.colors.background.secondary}; - height: 36px; + height: ${HEADER_HEIGHT}px; align-items: center; `, rowContainer: css` @@ -218,6 +192,12 @@ const getStyles = (theme: GrafanaTheme2) => { &:hover { background-color: ${rowHoverBg}; } + + &:not(:hover) div[role='cell'] { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } `, typeIcon: css` margin-left: 5px; diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 7abdc5aea81..e5ea9f5ff79 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -1,39 +1,43 @@ +import { css, cx } from '@emotion/css'; import React from 'react'; import SVG from 'react-inlinesvg'; -import { DataFrameView, DataSourceRef, Field } from '@grafana/data'; +import { Field } from '@grafana/data'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { Checkbox, Icon, IconName, TagList } from '@grafana/ui'; import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell'; -import { LocationInfo } from '../../service'; +import { QueryResponse, SearchResultMeta } from '../../service'; import { SelectionChecker, SelectionToggle } from '../selection'; -import { FieldAccess, TableColumn } from './SearchResultsTable'; +import { TableColumn } from './SearchResultsTable'; + +const TYPE_COLUMN_WIDTH = 130; +const DATASOURCE_COLUMN_WIDTH = 200; +const LOCATION_COLUMN_WIDTH = 200; +const TAGS_COLUMN_WIDTH = 200; export const generateColumns = ( - data: DataFrameView, - isDashboardList: boolean, + response: QueryResponse, availableWidth: number, selection: SelectionChecker | undefined, selectionToggle: SelectionToggle | undefined, styles: { [key: string]: string }, - tags: string[], - onTagFilterChange: (tags: string[]) => void, - onDatasourceChange: (datasource?: string) => void + onTagSelected: (tag: string) => void, + onDatasourceChange?: (datasource?: string) => void ): TableColumn[] => { const columns: TableColumn[] = []; - const uidField = data.fields.uid!; - const kindField = data.fields.kind!; - const access = data.fields; + const access = response.view.fields; + const uidField = access.uid; + const kindField = access.kind; - availableWidth -= 8; // ??? let width = 50; if (selection && selectionToggle) { width = 30; columns.push({ id: `column-checkbox`, + width, Header: () => (
    ), - width, Cell: (p) => { const uid = uidField.values.get(p.row.index); const kind = kindField ? kindField.values.get(p.row.index) : 'dashboard'; // HACK for now @@ -71,55 +74,33 @@ export const generateColumns = ( } // Name column - width = Math.max(availableWidth * 0.2, 200); + width = Math.max(availableWidth * 0.2, 300); columns.push({ - Cell: DefaultCell, + Cell: (p) => { + const name = access.name.values.get(p.row.index); + return ( +
    + {name} +
    + ); + }, id: `column-name`, field: access.name!, Header: 'Name', - accessor: (row: any, i: number) => { - const name = access.name!.values.get(i); - return name; - }, width, }); availableWidth -= width; - const TYPE_COLUMN_WIDTH = 130; - const DATASOURCE_COLUMN_WIDTH = 200; - const INFO_COLUMN_WIDTH = 100; - const LOCATION_COLUMN_WIDTH = 200; - const TAGS_COLUMN_WIDTH = 200; - width = TYPE_COLUMN_WIDTH; - if (isDashboardList) { - columns.push({ - Cell: DefaultCell, - id: `column-type`, - field: access.name!, - Header: 'Type', - accessor: (row: any, i: number) => { - return ( -
    - - Dashboard -
    - ); - }, - width, - }); - availableWidth -= width; - } else { - columns.push(makeTypeColumn(access.kind, access.type, width, styles.typeText, styles.typeIcon)); - availableWidth -= width; - } + columns.push(makeTypeColumn(access.kind, access.panel_type, width, styles.typeText, styles.typeIcon)); + availableWidth -= width; // Show datasources if we have any - if (access.datasource && hasFieldValue(access.datasource)) { + if (access.ds_uid && onDatasourceChange) { width = DATASOURCE_COLUMN_WIDTH; columns.push( makeDataSourceColumn( - access.datasource, + access.ds_uid, width, styles.typeIcon, styles.datasourceItem, @@ -131,71 +112,51 @@ export const generateColumns = ( } // Show tags if we have any - if (access.tags && hasFieldValue(access.tags)) { + if (access.tags) { width = TAGS_COLUMN_WIDTH; - columns.push(makeTagsColumn(access.tags, width, styles.tagList, tags, onTagFilterChange)); + columns.push(makeTagsColumn(access.tags, width, styles.tagList, onTagSelected)); availableWidth -= width; } - if (isDashboardList) { - width = Math.max(availableWidth, INFO_COLUMN_WIDTH); + width = Math.max(availableWidth, LOCATION_COLUMN_WIDTH); + const meta = response.view.dataFrame.meta?.custom as SearchResultMeta; + if (meta?.locationInfo) { columns.push({ - Cell: DefaultCell, - id: `column-info`, - field: access.url!, - Header: 'Info', - accessor: (row: any, i: number) => { - const panelCount = access.panelCount?.values.get(i); - return
    {panelCount != null && Panels: {panelCount}}
    ; + Cell: (p) => { + const parts = (access.location?.values.get(p.row.index) ?? '').split('/'); + return ( +
    + ); }, - width: width, - }); - } else { - width = Math.max(availableWidth, LOCATION_COLUMN_WIDTH); - columns.push({ - Cell: DefaultCell, id: `column-location`, field: access.location ?? access.url, Header: 'Location', - accessor: (row: any, i: number) => { - const location = access.location?.values.get(i) as LocationInfo[]; - if (location) { - return ( -
    - {location.map((v, id) => ( - { - e.preventDefault(); - alert('CLICK: ' + v.name); - }} - > - {v.name} - - ))} -
    - ); - } - return null; - }, - width: width, + width, }); } return columns; }; -function hasFieldValue(field: Field): boolean { - for (let i = 0; i < field.values.length; i++) { - const v = field.values.get(i); - if (v && v.length) { - return true; - } - } - return false; -} - function getIconForKind(v: string): IconName { if (v === 'dashboard') { return 'apps'; @@ -207,52 +168,51 @@ function getIconForKind(v: string): IconName { } function makeDataSourceColumn( - field: Field, + field: Field, width: number, iconClass: string, datasourceItemClass: string, invalidDatasourceItemClass: string, onDatasourceChange: (datasource?: string) => void ): TableColumn { + const srv = getDataSourceSrv(); return { - Cell: DefaultCell, id: `column-datasource`, field, Header: 'Data source', - accessor: (row: any, i: number) => { - const dslist = field.values.get(i); - if (dslist?.length) { - const srv = getDataSourceSrv(); - return ( -
    - {dslist.map((v, i) => { - const settings = srv.getInstanceSettings(v); - const icon = settings?.meta?.info?.logos?.small; - if (icon) { - return ( - { - e.stopPropagation(); - e.preventDefault(); - onDatasourceChange(settings.uid); - }} - > - - {settings.name} - - ); - } + Cell: (p) => { + const dslist = field.values.get(p.row.index); + if (!dslist?.length) { + return null; + } + return ( +
    + {dslist.map((v, i) => { + const settings = srv.getInstanceSettings(v); + const icon = settings?.meta?.info?.logos?.small; + if (icon) { return ( - - {v.type} + { + e.stopPropagation(); + e.preventDefault(); + onDatasourceChange(settings.uid); + }} + > + + {settings.name} ); - })} -
    - ); - } - return null; + } + return ( + + {v} + + ); + })} +
    + ); }, width, }; @@ -303,7 +263,6 @@ function makeTypeColumn( break; } } - return (
    @@ -319,27 +278,23 @@ function makeTagsColumn( field: Field, width: number, tagListClass: string, - currentTagFilter: string[], - onTagFilterChange: (tags: string[]) => void + onTagSelected: (tag: string) => void ): TableColumn { - const updateTagFilter = (tag: string) => { - if (!currentTagFilter.includes(tag)) { - onTagFilterChange([...currentTagFilter, tag]); - } - }; - return { - Cell: DefaultCell, - id: `column-tags`, - field: field, - Header: 'Tags', - accessor: (row: any, i: number) => { - const tags = field.values.get(i); + Cell: (p) => { + const tags = field.values.get(p.row.index); if (tags) { - return ; + return ( +
    + +
    + ); } return null; }, + id: `column-tags`, + field: field, + Header: 'Tags', width, }; } diff --git a/public/app/features/search/service/backend.ts b/public/app/features/search/service/backend.ts deleted file mode 100644 index bd391ef95f7..00000000000 --- a/public/app/features/search/service/backend.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { lastValueFrom } from 'rxjs'; - -import { - ArrayVector, - DataFrame, - DataFrameType, - DataFrameView, - Field, - FieldType, - getDisplayProcessor, - Vector, -} from '@grafana/data'; -import { config, getDataSourceSrv } from '@grafana/runtime'; -import { TermCount } from 'app/core/components/TagFilter/TagFilter'; -import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; -import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; - -import { QueryFilters } from './types'; - -import { QueryResult } from '.'; - -// The raw restuls from query server -export interface RawIndexData { - folder?: DataFrame; - dashboard?: DataFrame; - panel?: DataFrame; -} - -export type rawIndexSupplier = () => Promise; - -export async function getRawIndexData(): Promise { - const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource; - const rsp = await lastValueFrom( - ds.query({ - targets: [ - { refId: 'A', queryType: GrafanaQueryType.Search }, // gets all data - ], - } as any) - ); - - const data: RawIndexData = {}; - for (const f of rsp.data) { - const frame = f as DataFrame; - for (const field of frame.fields) { - // Parse tags/ds from JSON string - if (field.name === 'tags' || field.name === 'datasource') { - const values = field.values.toArray().map((v) => { - if (v?.length) { - try { - const arr = JSON.parse(v); - return arr.length ? arr : undefined; - } catch {} - } - return undefined; - }); - field.type = FieldType.other; // []string - field.values = new ArrayVector(values); - } - - field.display = getDisplayProcessor({ field, theme: config.theme2 }); - } - frame.meta = { - type: DataFrameType.DirectoryListing, - }; - - switch (frame.name) { - case 'dashboards': - data.dashboard = frame; - break; - case 'panels': - data.panel = frame; - break; - case 'folders': - data.folder = frame; - break; - } - } - return data; -} - -export function buildStatsTable(field?: Field): DataFrame { - if (!field) { - return { length: 0, fields: [] }; - } - - const counts = new Map(); - for (let i = 0; i < field.values.length; i++) { - const k = field.values.get(i); - const v = counts.get(k) ?? 0; - counts.set(k, v + 1); - } - - // Sort largest first - counts[Symbol.iterator] = function* () { - yield* [...this.entries()].sort((a, b) => b[1] - a[1]); - }; - - const keys: any[] = []; - const vals: number[] = []; - - for (let [k, v] of counts) { - keys.push(k); - vals.push(v); - } - - return { - fields: [ - { ...field, values: new ArrayVector(keys) }, - { name: 'Count', type: FieldType.number, values: new ArrayVector(vals), config: {} }, - ], - length: keys.length, - }; -} - -export function getTermCounts(field?: Field): TermCount[] { - if (!field) { - return []; - } - - const counts = new Map(); - for (let i = 0; i < field.values.length; i++) { - const k = field.values.get(i); - if (k == null || !k.length) { - continue; - } - if (Array.isArray(k)) { - for (const sub of k) { - const v = counts.get(sub) ?? 0; - counts.set(sub, v + 1); - } - } else { - const v = counts.get(k) ?? 0; - counts.set(k, v + 1); - } - } - - // Sort largest first - counts[Symbol.iterator] = function* () { - yield* [...this.entries()].sort((a, b) => b[1] - a[1]); - }; - - const terms: TermCount[] = []; - for (let [term, count] of counts) { - terms.push({ - term, - count, - }); - } - - return terms; -} - -export function filterFrame(frame: DataFrame, filter?: QueryFilters): DataFrame { - if (!filter) { - return frame; - } - const view = new DataFrameView(frame); - const keep: number[] = []; - - const ds = filter.datasource ? view.fields.datasource : undefined; - const tags = filter.tags?.length ? view.fields.tags : undefined; - - let ok = true; - for (let i = 0; i < view.length; i++) { - ok = true; - - if (tags) { - const v = tags.values.get(i); - if (!v) { - ok = false; - } else { - for (const t of filter.tags!) { - if (!v.includes(t)) { - ok = false; - break; - } - } - } - } - - if (ok && ds && filter.datasource) { - ok = false; - const v = ds.values.get(i); - if (v) { - for (const d of v) { - if (d.uid === filter.datasource) { - ok = true; - break; - } - } - } - } - - if (ok) { - keep.push(i); - } - } - - return { - meta: frame.meta, - name: frame.name, - fields: frame.fields.map((f) => ({ ...f, values: filterValues(keep, f.values) })), - length: keep.length, - }; -} - -function filterValues(keep: number[], raw: Vector): Vector { - const values = new Array(keep.length); - for (let i = 0; i < keep.length; i++) { - values[i] = raw.get(keep[i]); - } - return new ArrayVector(values); -} diff --git a/public/app/features/search/service/bluge.ts b/public/app/features/search/service/bluge.ts new file mode 100644 index 00000000000..0104d163e5f --- /dev/null +++ b/public/app/features/search/service/bluge.ts @@ -0,0 +1,133 @@ +import { lastValueFrom } from 'rxjs'; + +import { ArrayVector, DataFrame, DataFrameView, getDisplayProcessor } from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import { TermCount } from 'app/core/components/TagFilter/TagFilter'; +import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; + +import { DashboardQueryResult, GrafanaSearcher, QueryResponse, SearchQuery, SearchResultMeta } from '.'; + +export class BlugeSearcher implements GrafanaSearcher { + async search(query: SearchQuery): Promise { + if (query.facet?.length) { + throw 'facets not supported!'; + } + return doSearchQuery(query); + } + + async list(location: string): Promise { + return doSearchQuery({ query: `list:${location ?? ''}` }); + } + + async tags(query: SearchQuery): Promise { + const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource; + const target = { + ...query, + refId: 'A', + queryType: GrafanaQueryType.Search, + query: query.query ?? '*', + facet: [{ field: 'tag' }], + limit: 1, // 0 would be better, but is ignored by the backend + }; + + const data = ( + await lastValueFrom( + ds.query({ + targets: [target], + } as any) + ) + ).data as DataFrame[]; + for (const frame of data) { + if (frame.fields[0].name === 'tag') { + return getTermCountsFrom(frame); + } + } + return []; + } +} + +const firstPageSize = 50; +const nextPageSizes = 100; + +export async function doSearchQuery(query: SearchQuery): Promise { + const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource; + const target = { + ...query, + refId: 'A', + queryType: GrafanaQueryType.Search, + query: query.query ?? '*', + limit: firstPageSize, + }; + const rsp = await lastValueFrom( + ds.query({ + targets: [target], + } as any) + ); + + const first = (rsp.data?.[0] as DataFrame) ?? { fields: [], length: 0 }; + for (const field of first.fields) { + field.display = getDisplayProcessor({ field, theme: config.theme2 }); + } + + const meta = first.meta?.custom as SearchResultMeta; + const view = new DataFrameView(first); + return { + totalRows: meta.count ?? first.length, + view, + loadMoreItems: async (startIndex: number, stopIndex: number): Promise => { + console.log('LOAD NEXT PAGE', { startIndex, stopIndex, length: view.dataFrame.length }); + const from = view.dataFrame.length; + const limit = stopIndex - from; + if (limit < 0) { + return; + } + const frame = ( + await lastValueFrom( + ds.query({ + targets: [{ ...target, refId: 'Page', facet: undefined, from, limit: Math.max(limit, nextPageSizes) }], + } as any) + ) + ).data?.[0] as DataFrame; + + if (!frame) { + console.log('no results', frame); + return; + } + if (frame.fields.length !== view.dataFrame.fields.length) { + console.log('invalid shape', frame, view.dataFrame); + return; + } + + // Append the raw values to the same array buffer + const length = frame.length + view.dataFrame.length; + for (let i = 0; i < frame.fields.length; i++) { + const values = (view.dataFrame.fields[i].values as ArrayVector).buffer; + values.push(...frame.fields[i].values.toArray()); + } + view.dataFrame.length = length; + + // Add all the location lookup info + const submeta = frame.meta?.custom as SearchResultMeta; + if (submeta?.locationInfo && meta) { + for (const [key, value] of Object.entries(submeta.locationInfo)) { + meta.locationInfo[key] = value; + } + } + return; + }, + isItemLoaded: (index: number): boolean => { + return index < view.dataFrame.length; + }, + }; +} + +function getTermCountsFrom(frame: DataFrame): TermCount[] { + const keys = frame.fields[0].values; + const vals = frame.fields[1].values; + const counts: TermCount[] = []; + for (let i = 0; i < frame.length; i++) { + counts.push({ term: keys.get(i), count: vals.get(i) }); + } + return counts; +} diff --git a/public/app/features/search/service/minisearcher.ts b/public/app/features/search/service/minisearcher.ts deleted file mode 100644 index 194d9e90ee9..00000000000 --- a/public/app/features/search/service/minisearcher.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { isArray, isString } from 'lodash'; -import MiniSearch from 'minisearch'; - -import { ArrayVector, DataFrame, DataSourceRef, Field, FieldType, getDisplayProcessor, Vector } from '@grafana/data'; -import { config } from '@grafana/runtime'; - -import { filterFrame, getRawIndexData, RawIndexData, rawIndexSupplier } from './backend'; -import { GrafanaSearcher, QueryFilters, QueryResponse } from './types'; - -import { LocationInfo } from '.'; - -export type SearchResultKind = keyof RawIndexData; - -interface InputDoc { - kind: SearchResultKind; - index: number; - - // Fields - id?: Vector; - url?: Vector; - uid?: Vector; - name?: Vector; - folder?: Vector; - description?: Vector; - dashboardID?: Vector; - location?: Vector; - datasource?: Vector; - type?: Vector; - tags?: Vector; // JSON strings? -} - -interface CompositeKey { - kind: SearchResultKind; - index: number; -} - -// This implements search in the frontend using the minisearch library -export class MiniSearcher implements GrafanaSearcher { - lookup = new Map(); - data: RawIndexData = {}; - index?: MiniSearch; - - constructor(private supplier: rawIndexSupplier = getRawIndexData) { - // waits for first request to load data - } - - private async initIndex() { - const data = await this.supplier(); - - const searcher = new MiniSearch({ - idField: '__id', - fields: ['name', 'description', 'tags', 'type', 'tags'], // fields to index for full-text search - searchOptions: { - boost: { - name: 3, - description: 1, - }, - // boost dashboard matches first - boostDocument: (documentId: any, term: string) => { - const kind = documentId.kind; - if (kind === 'dashboard') { - return 1.4; - } - if (kind === 'folder') { - return 1.2; - } - return 1; - }, - prefix: true, - fuzzy: (term) => (term.length > 4 ? 0.2 : false), - }, - extractField: (doc, name) => { - // return a composite key for the id - if (name === '__id') { - return { - kind: doc.kind, - index: doc.index, - } as any; - } - const values = (doc as any)[name] as Vector; - if (!values) { - return ''; - } - const value = values.get(doc.index); - if (isString(value)) { - return value as string; - } - if (isArray(value)) { - return value.join(' '); - } - return JSON.stringify(value); - }, - }); - - const lookup = new Map(); - for (const [key, frame] of Object.entries(data)) { - const kind = key as SearchResultKind; - const input = getInputDoc(kind, frame); - lookup.set(kind, input); - for (let i = 0; i < frame.length; i++) { - input.index = i; - searcher.add(input); - } - } - - // Construct the URL field for each panel - const folderIDToIndex = new Map(); - const folder = lookup.get('folder'); - const dashboard = lookup.get('dashboard'); - const panel = lookup.get('panel'); - if (folder?.id) { - for (let i = 0; i < folder.id?.length; i++) { - folderIDToIndex.set(folder.id.get(i), i); - } - } - - if (dashboard?.id && panel?.dashboardID && dashboard.url) { - let location: LocationInfo[][] = new Array(dashboard.id.length); - const dashIDToIndex = new Map(); - for (let i = 0; i < dashboard.id?.length; i++) { - dashIDToIndex.set(dashboard.id.get(i), i); - const folderId = dashboard.folder?.get(i); - if (folderId != null) { - const index = folderIDToIndex.get(folderId); - const name = folder?.name?.get(index!); - if (name) { - location[i] = [ - { - kind: 'folder', - name, - }, - ]; - } - } - } - dashboard.location = new ArrayVector(location); // folder name - - location = new Array(panel.dashboardID.length); - const urls: string[] = new Array(location.length); - for (let i = 0; i < panel.dashboardID.length; i++) { - const dashboardID = panel.dashboardID.get(i); - const index = dashIDToIndex.get(dashboardID); - if (index != null) { - const idx = panel.id?.get(i); - urls[i] = dashboard.url.get(index) + '?viewPanel=' + idx; - - const parent = dashboard.location.get(index) ?? []; - const name = dashboard.name?.get(index) ?? '?'; - location[i] = [...parent, { kind: 'dashboard', name }]; - } - } - panel.url = new ArrayVector(urls); - panel.location = new ArrayVector(location); - } - - this.index = searcher; - this.data = data; - this.lookup = lookup; - } - - async search(query: string, filter?: QueryFilters): Promise { - if (!this.index) { - await this.initIndex(); - } - - // empty query can return everything - if (!query && this.data.dashboard) { - return { - body: filterFrame(this.data.dashboard, filter), - }; - } - - const found = this.index!.search(query); - - // frame fields - const uid: string[] = []; - const url: string[] = []; - const kind: string[] = []; - const type: string[] = []; - const name: string[] = []; - const tags: string[][] = []; - const location: LocationInfo[][] = []; - const datasource: DataSourceRef[][] = []; - const info: any[] = []; - const score: number[] = []; - - for (const res of found) { - const key = res.id as CompositeKey; - const index = key.index; - const input = this.lookup.get(key.kind); - if (!input) { - continue; - } - - if (filter && !shouldKeep(filter, input, index)) { - continue; - } - - uid.push(input.uid?.get(index)!); - url.push(input.url?.get(index) ?? '?'); - location.push(input.location?.get(index) as any); - datasource.push(input.datasource?.get(index) as any); - tags.push(input.tags?.get(index) as any); - kind.push(key.kind); - name.push(input.name?.get(index) ?? '?'); - type.push(input.type?.get(index)!); - info.push(res.match); // ??? - score.push(res.score); - } - const fields: Field[] = [ - { name: 'uid', config: {}, type: FieldType.string, values: new ArrayVector(uid) }, - { name: 'kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) }, - { name: 'name', config: {}, type: FieldType.string, values: new ArrayVector(name) }, - { - name: 'url', - config: {}, - type: FieldType.string, - values: new ArrayVector(url), - }, - { name: 'type', config: {}, type: FieldType.string, values: new ArrayVector(type) }, - { name: 'info', config: {}, type: FieldType.other, values: new ArrayVector(info) }, - { name: 'tags', config: {}, type: FieldType.other, values: new ArrayVector(tags) }, - { name: 'location', config: {}, type: FieldType.other, values: new ArrayVector(location) }, - { name: 'datasource', config: {}, type: FieldType.other, values: new ArrayVector(datasource) }, - { name: 'score', config: {}, type: FieldType.number, values: new ArrayVector(score) }, - ]; - for (const field of fields) { - field.display = getDisplayProcessor({ field, theme: config.theme2 }); - } - return { - body: { - fields, - length: kind.length, - }, - }; - } -} - -function shouldKeep(filter: QueryFilters, doc: InputDoc, index: number): boolean { - if (filter.tags) { - const tags = doc.tags?.get(index); - if (!tags?.length) { - return false; - } - for (const t of filter.tags) { - if (!tags.includes(t)) { - return false; - } - } - } - - let keep = true; - // Any is OK - if (filter.datasource) { - keep = false; - const dss = doc.datasource?.get(index); - if (dss) { - for (const ds of dss) { - if (ds.uid === filter.datasource) { - keep = true; - break; - } - } - } - } - return keep; -} - -function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc { - const input: InputDoc = { - kind, - index: 0, - }; - for (const field of frame.fields) { - switch (field.name) { - case 'name': - case 'Name': - input.name = field.values; - break; - case 'Description': - case 'Description': - input.description = field.values; - break; - case 'url': - case 'URL': - input.url = field.values; - break; - case 'uid': - case 'UID': - input.uid = field.values; - break; - case 'id': - case 'ID': - input.id = field.values; - break; - case 'Tags': - case 'tags': - input.tags = field.values; - break; - case 'DashboardID': - case 'dashboardID': - input.dashboardID = field.values; - break; - case 'Type': - case 'type': - input.type = field.values; - break; - case 'folderID': - case 'FolderID': - input.folder = field.values; - break; - case 'datasource': - case 'dsList': - case 'DSList': - input.datasource = field.values; - break; - } - } - return input; -} diff --git a/public/app/features/search/service/searcher.test.ts b/public/app/features/search/service/searcher.test.ts deleted file mode 100644 index 26742a1bc3e..00000000000 --- a/public/app/features/search/service/searcher.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { toDataFrame } from '@grafana/data'; - -import { rawIndexSupplier } from './backend'; -import { MiniSearcher } from './minisearcher'; - -jest.mock('@grafana/data', () => ({ - ...jest.requireActual('@grafana/data'), - getDisplayProcessor: jest - .fn() - .mockName('mockedGetDisplayProcesser') - .mockImplementation(() => ({})), -})); - -describe('simple search', () => { - it('should support frontend search', async () => { - const supplier: rawIndexSupplier = () => - Promise.resolve({ - dashboard: toDataFrame([ - { Name: 'A name (dash)', Description: 'A descr (dash)' }, - { Name: 'B name (dash)', Description: 'B descr (dash)' }, - ]), - panel: toDataFrame([ - { Name: 'A name (panels)', Description: 'A descr (panels)' }, - { Name: 'B name (panels)', Description: 'B descr (panels)' }, - ]), - }); - - const searcher = new MiniSearcher(supplier); - let results = await searcher.search('name'); - expect(results.body.fields[2].values.toArray()).toMatchInlineSnapshot(` - Array [ - "A name (dash)", - "B name (dash)", - "A name (panels)", - "B name (panels)", - ] - `); - - results = await searcher.search('B'); - expect(results.body.fields[2].values.toArray()).toMatchInlineSnapshot(` - Array [ - "B name (dash)", - "B name (panels)", - ] - `); - - // All fields must have display set - for (const field of results.body.fields) { - expect(field.display).toBeDefined(); - } - - // Empty search has defined values - results = await searcher.search(''); - expect(results.body.fields.length).toBeGreaterThan(0); - }); -}); diff --git a/public/app/features/search/service/searcher.ts b/public/app/features/search/service/searcher.ts index a1cea005ade..ff6675339d6 100644 --- a/public/app/features/search/service/searcher.ts +++ b/public/app/features/search/service/searcher.ts @@ -1,11 +1,11 @@ -import { MiniSearcher } from './minisearcher'; +import { BlugeSearcher } from './bluge'; import { GrafanaSearcher } from './types'; let searcher: GrafanaSearcher | undefined = undefined; export function getGrafanaSearcher(): GrafanaSearcher { if (!searcher) { - searcher = new MiniSearcher(); + searcher = new BlugeSearcher(); } return searcher!; } diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index 51ee071a1b3..adff98d3467 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -1,32 +1,67 @@ -import { DataFrame, DataSourceRef } from '@grafana/data'; +import { DataFrameView } from '@grafana/data'; +import { TermCount } from 'app/core/components/TagFilter/TagFilter'; -export interface QueryResult { +export interface FacetField { + field: string; + count?: number; +} + +export interface SearchQuery { + query?: string; + location?: string; + sort?: string; + ds_uid?: string; + tags?: string[]; + kind?: string[]; + uid?: string[]; + id?: number[]; + facet?: FacetField[]; + explain?: boolean; + accessInfo?: boolean; + hasPreview?: string; // theme + limit?: number; + from?: number; +} + +export interface DashboardQueryResult { kind: string; // panel, dashboard, folder name: string; uid: string; - description?: string; url: string; // link to value (unique) - tags?: string[]; - location?: LocationInfo[]; // the folder name - datasource?: DataSourceRef[]; + panel_type: string; + tags: string[]; + location: string; // url that can be split + ds_uid: string[]; score?: number; } export interface LocationInfo { - kind: 'folder' | 'dashboard'; + kind: string; name: string; + url: string; } -export interface QueryFilters { - kind?: string; // limit to a single type - tags?: string[]; // match all tags - datasource?: string; // limit to a single datasource +export interface SearchResultMeta { + count: number; + max_score: number; + locationInfo: Record; } export interface QueryResponse { - body: DataFrame; + view: DataFrameView; + + /** Supports lazy loading. This will mutate the `view` object above, adding rows as needed */ + loadMoreItems: (startIndex: number, stopIndex: number) => Promise; + + /** Checks if a row in the view needs to be added */ + isItemLoaded: (index: number) => boolean; + + /** the total query results size */ + totalRows: number; } export interface GrafanaSearcher { - search: (query: string, filter?: QueryFilters) => Promise; + search: (query: SearchQuery) => Promise; + list: (location: string) => Promise; + tags: (query: SearchQuery) => Promise; } diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 50c09bf0efa..a561c21eb63 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -9,7 +9,8 @@ import { DataFrame, } from '@grafana/data'; import { config, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; -import { InlineField, Select, Alert, Input, InlineFieldRow } from '@grafana/ui'; +import { InlineField, Select, Alert, Input, InlineFieldRow, CodeEditor } from '@grafana/ui'; +import { SearchQuery } from 'app/features/search/service'; import { GrafanaDatasource } from '../datasource'; import { defaultQuery, GrafanaQuery, GrafanaQueryType } from '../types'; @@ -351,20 +352,69 @@ export class QueryEditor extends PureComponent { this.checkAndUpdateValue('query', e.target.value); }; + onSaveSearchJSON = (rawSearchJSON: string) => { + try { + const json = JSON.parse(rawSearchJSON) as GrafanaQuery; + json.queryType = GrafanaQueryType.Search; + this.props.onChange(json); + this.props.onRunQuery(); + } catch (ex) { + console.log('UNABLE TO parse search', rawSearchJSON, ex); + } + }; + renderSearch() { - let { query } = this.props.query; + let query = (this.props.query ?? {}) as SearchQuery; + const emptySearchQuery: SearchQuery = { + query: '*', + location: '', // general, etc + ds_uid: '', + sort: 'score desc', + tags: [], + kind: ['dashboard', 'folder'], + uid: [], + id: [], + explain: true, + accessInfo: true, + facet: [{ field: 'kind' }, { field: 'tag' }, { field: 'location' }], + hasPreview: 'dark', + from: 0, + limit: 20, + }; + + const json = JSON.stringify(query ?? {}, null, 2); + for (const [key, val] of Object.entries(emptySearchQuery)) { + if ((query as any)[key] == null) { + (query as any)[key] = val; + } + } + return ( - - - - - + <> + + This interface to the grafana search API is experimental, and subject to change at any time without notice + + + + + + + + ); } diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index 694f3197413..1773d251f97 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -23,7 +23,7 @@ export interface GrafanaQuery extends DataQuery { buffer?: number; path?: string; // for list and read query?: string; // for query endpoint -} +} // NOTE, query will have more field!!! export const defaultQuery: GrafanaQuery = { refId: 'A', diff --git a/yarn.lock b/yarn.lock index dbe448494a5..bf108f066f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10879,7 +10879,17 @@ __metadata: languageName: node linkType: hard -"@types/react-window@npm:1.8.5": +"@types/react-window-infinite-loader@npm:^1": + version: 1.0.6 + resolution: "@types/react-window-infinite-loader@npm:1.0.6" + dependencies: + "@types/react": "*" + "@types/react-window": "*" + checksum: d4648dfb44614e4f0137d7b77eb1868b0c5252f451a78edfc4520e508157ce7687d4b7d9efd6df8f01e72e0d92224338b8c8d934220f32a3081b528599a25829 + languageName: node + linkType: hard + +"@types/react-window@npm:*, @types/react-window@npm:1.8.5": version: 1.8.5 resolution: "@types/react-window@npm:1.8.5" dependencies: @@ -20969,6 +20979,7 @@ __metadata: "@types/react-transition-group": 4.4.4 "@types/react-virtualized-auto-sizer": 1.0.1 "@types/react-window": 1.8.5 + "@types/react-window-infinite-loader": ^1 "@types/redux-mock-store": 1.0.3 "@types/reselect": 2.2.0 "@types/semver": 7.3.9 @@ -21067,7 +21078,6 @@ __metadata: lru-cache: 7.9.0 memoize-one: 6.0.0 mini-css-extract-plugin: 2.6.0 - minisearch: 5.0.0-beta1 moment: 2.29.2 moment-timezone: 0.5.34 monaco-editor: ^0.31.1 @@ -21122,6 +21132,7 @@ __metadata: react-use: 17.3.2 react-virtualized-auto-sizer: 1.0.6 react-window: 1.8.6 + react-window-infinite-loader: ^1.0.7 redux: 4.1.2 redux-mock-store: 1.5.4 redux-thunk: 2.4.1 @@ -26632,13 +26643,6 @@ __metadata: languageName: node linkType: hard -"minisearch@npm:5.0.0-beta1": - version: 5.0.0-beta1 - resolution: "minisearch@npm:5.0.0-beta1" - checksum: 7c5ba8b2d1b52df0724e69183306b4204ae4cdc0102813da7f12a8f90a7b4efe7add4c54814ec1bb63f8bd5be599373af9055e0d68ceeef3d225f0a2c7637699 - languageName: node - linkType: hard - "minizlib@npm:^1.3.3": version: 1.3.3 resolution: "minizlib@npm:1.3.3" @@ -31894,6 +31898,16 @@ __metadata: languageName: node linkType: hard +"react-window-infinite-loader@npm:^1.0.7": + version: 1.0.7 + resolution: "react-window-infinite-loader@npm:1.0.7" + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 + checksum: 5c11a8958274f79a44672e9b76ca9bfe2ba09d9f6d1c747827f0a2c3da96e423bb059d033ffd866efa94ed8989b405ee7b9c002b11281ca0697eb7ed73caf85e + languageName: node + linkType: hard + "react-window@npm:1.8.6": version: 1.8.6 resolution: "react-window@npm:1.8.6"