Search: use bluge index for frontend search (playground) (#48847)

This commit is contained in:
Ryan McKinley 2022-05-11 08:32:13 -07:00 committed by GitHub
parent d31d300ce1
commit 3a32a73459
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 918 additions and 976 deletions

View File

@ -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",

View File

@ -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<HTMLInputElement>) => {
e.preventDefault();
setInputValue(e.currentTarget.value);
};
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>;
@ -55,20 +68,12 @@ export default function SearchPage() {
// This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => {
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<HTMLInputElement>) => {
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 <Spinner />;
}
const value = results.value;
if ((!value || !value.totalRows) && !isFolders) {
if (results.loading && !value) {
return <Spinner />;
}
const df = results.value?.body;
if (!df || !df.length) {
return (
<div className={styles.noResults}>
<div>No results found for your query.</div>
@ -117,103 +120,58 @@ export default function SearchPage() {
);
}
return (
<AutoSizer style={{ width: '100%', height: '700px' }}>
{({ width, height }) => {
if (showPreviews) {
const view = new DataFrameView<QueryResult>(df);
const selection = showManage ? searchSelection.isSelected : undefined;
if (layout === SearchLayout.Folders) {
return <FolderView selection={selection} selectionToggle={toggleSelection} onTagSelected={onTagSelected} />;
}
// 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 (
<div style={{ height: '100%', width: '100%' }}>
<AutoSizer>
{({ 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 (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width - 2}
>
{({ 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 <SearchResultsGrid {...props} />;
}
// 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 ? (
<li style={style} className={styles.virtualizedGridItemWrapper}>
<SearchCard key={item.uid} {...itemProps} item={facade} />
</li>
) : null;
}}
</FixedSizeGrid>
);
}
return (
<>
<SearchResultsTable
data={df}
selection={showManage ? searchSelection.isSelected : undefined}
selectionToggle={toggleSelection}
layout={layout}
width={width - 5}
height={height}
tags={query.tag}
onTagFilterChange={onTagChange}
onDatasourceChange={onDatasourceChange}
/>
</>
);
}}
</AutoSizer>
return <SearchResultsTable {...props} />;
}}
</AutoSizer>
</div>
);
};
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents>
<Page.Contents
className={css`
display: flex;
flex-direction: column;
`}
>
<Input
value={query.query}
value={inputValue}
onChange={onSearchQueryChange}
autoFocus
spellCheck={false}
placeholder="Search for dashboards and panels"
className={styles.searchInput}
suffix={results.loading ? <Spinner /> : null}
/>
<InlineFieldRow>
<InlineField label="Show manage options">
<InlineSwitch value={showManage} onChange={() => setShowManage(!showManage)} />
</InlineField>
</InlineFieldRow>
<br />
<hr />
{Boolean(searchSelection.items.size > 0) ? (
<ManageActions items={searchSelection.items} />
@ -235,14 +193,13 @@ export default function SearchPage() {
/>
)}
{showPreviews && (
{layout === SearchLayout.Grid && (
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={showPreviews}
showPreviews={true}
onRemove={() => onLayoutChange(SearchLayout.List)}
/>
)}
{renderResults()}
</Page.Contents>
</Page>
@ -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};

View File

@ -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<Props> = ({

View File

@ -19,7 +19,7 @@ export const ConfirmDeleteModal: FC<Props> = ({ 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;

View File

@ -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<SectionHeaderProps> = ({ 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 (
<CollapsableSection
isOpen={sectionExpanded ?? false}
onToggle={onSectionExpand}
className={styles.wrapper}
contentClassName={styles.content}
loading={results.loading}
labelId={labelId}
label={
<>
{selectionToggle && selection && (
<div onClick={(v) => console.log(v)} className={styles.checkbox}>
<Checkbox value={selection(section.kind, section.uid)} aria-label="Select folder" />
</div>
)}
<div className={styles.icon}>
<Icon name={icon as any} />
</div>
<div className={styles.text}>
<span id={labelId}>{section.title}</span>
{section.url && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" /> Go to folder
</a>
)}
</div>
</>
}
>
{results.value && (
<ul>
{results.value.map((v) => (
<SearchItem key={v.uid} item={v} onTagSelected={onTagSelected} />
))}
</ul>
)}
</CollapsableSection>
);
};
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;
`,
};
});

View File

@ -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<SearchResultsProps, 'selection' | 'selectionToggle' | 'onTagSelected'>) => {
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 <Spinner />;
}
if (!results.value) {
return <div>?</div>;
}
return (
<div className={styles.wrapper}>
{results.value.map((section) => {
return (
<div data-testid={selectors.components.Search} className={styles.section} key={section.title}>
{section.title && (
<FolderSection
selection={selection}
selectionToggle={selectionToggle}
onTagSelected={onTagSelected}
section={section}
/>
)}
</div>
);
})}
</div>
);
};
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};
`,
};
};

View File

@ -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 (
<InfiniteLoader isItemLoaded={response.isItemLoaded} itemCount={itemCount} loadMoreItems={response.loadMoreItems}>
{({ onItemsRendered, ref }) => (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width - 2}
>
{({ 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 ? (
<li style={style} className={styles.virtualizedGridItemWrapper}>
<SearchCard key={item.uid} {...itemProps} item={facade} />
</li>
) : null;
}}
</FixedSizeGrid>
)}
</InfiniteLoader>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
virtualizedGridItemWrapper: css`
padding: 4px;
`,
wrapper: css`
display: flex;
flex-direction: column;
> ul {
list-style: none;
}
`,
});

View File

@ -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<FieldAccess>(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 (
<div {...row.getRowProps({ style })} className={styles.rowContainer}>
{row.cells.map((cell: Cell, index: number) => {
@ -122,7 +90,6 @@ export const SearchResultsTable = ({
if (skipHREF.has(cell.column.id)) {
return body;
}
return (
<a href={url} key={index} className={styles.cellWrapper}>
{body}
@ -132,11 +99,15 @@ export const SearchResultsTable = ({
</div>
);
},
[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 <div className={styles.noData}>No data</div>;
}
return (
<div {...getTableProps()} style={{ width }} aria-label={'Search result table'} role="table">
<div {...getTableProps()} aria-label="Search result table" role="table">
<div>
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
@ -157,19 +128,25 @@ export const SearchResultsTable = ({
</div>
<div {...getTableBodyProps()}>
{rows.length > 0 ? (
<FixedSizeList
height={height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
className={styles.tableBody}
>
{RenderRow}
</FixedSizeList>
) : (
<div className={styles.noData}>No data</div>
)}
<InfiniteLoader
isItemLoaded={response.isItemLoaded}
itemCount={rows.length}
loadMoreItems={response.loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={ref}
onItemsRendered={onItemsRendered}
height={height - HEADER_HEIGHT}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width="100%"
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
)}
</InfiniteLoader>
</div>
</div>
);
@ -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;

View File

@ -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<FieldAccess>,
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: () => (
<div className={styles.checkboxHeader}>
<Checkbox
@ -45,7 +49,6 @@ export const generateColumns = (
/>
</div>
),
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 (
<div {...p.cellProps} className={p.cellStyle}>
{name}
</div>
);
},
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 (
<div className={styles.typeText}>
<Icon name={'apps'} className={styles.typeIcon} />
Dashboard
</div>
);
},
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 <div className={styles.infoWrap}>{panelCount != null && <span>Panels: {panelCount}</span>}</div>;
Cell: (p) => {
const parts = (access.location?.values.get(p.row.index) ?? '').split('/');
return (
<div
{...p.cellProps}
className={cx(
p.cellStyle,
css`
padding-right: 10px;
`
)}
>
{parts.map((p) => {
const info = meta.locationInfo[p];
return info ? (
<a key={p} href={info.url} className={styles.locationItem}>
<Icon name={getIconForKind(info.kind)} /> {info.name}
</a>
) : (
<span key={p}>{p}</span>
);
})}
</div>
);
},
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 (
<div>
{location.map((v, id) => (
<span
key={id}
className={styles.locationItem}
onClick={(e) => {
e.preventDefault();
alert('CLICK: ' + v.name);
}}
>
<Icon name={getIconForKind(v.kind)} /> {v.name}
</span>
))}
</div>
);
}
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<DataSourceRef[]>,
field: Field<string[]>,
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 (
<div className={datasourceItemClass}>
{dslist.map((v, i) => {
const settings = srv.getInstanceSettings(v);
const icon = settings?.meta?.info?.logos?.small;
if (icon) {
return (
<span
key={i}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDatasourceChange(settings.uid);
}}
>
<img src={icon} width={14} height={14} title={settings.type} className={iconClass} />
{settings.name}
</span>
);
}
Cell: (p) => {
const dslist = field.values.get(p.row.index);
if (!dslist?.length) {
return null;
}
return (
<div {...p.cellProps} className={cx(p.cellStyle, datasourceItemClass)}>
{dslist.map((v, i) => {
const settings = srv.getInstanceSettings(v);
const icon = settings?.meta?.info?.logos?.small;
if (icon) {
return (
<span className={invalidDatasourceItemClass} key={i}>
{v.type}
<span
key={i}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDatasourceChange(settings.uid);
}}
>
<img src={icon} width={14} height={14} title={settings.type} className={iconClass} />
{settings.name}
</span>
);
})}
</div>
);
}
return null;
}
return (
<span className={invalidDatasourceItemClass} key={i}>
{v}
</span>
);
})}
</div>
);
},
width,
};
@ -303,7 +263,6 @@ function makeTypeColumn(
break;
}
}
return (
<div className={typeTextClass}>
<SVG src={icon} width={14} height={14} title={txt} className={iconClass} />
@ -319,27 +278,23 @@ function makeTagsColumn(
field: Field<string[]>,
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 <TagList className={tagListClass} tags={tags} onClick={updateTagFilter} />;
return (
<div {...p.cellProps} className={p.cellStyle}>
<TagList className={tagListClass} tags={tags} onClick={onTagSelected} />
</div>
);
}
return null;
},
id: `column-tags`,
field: field,
Header: 'Tags',
width,
};
}

View File

@ -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<RawIndexData>;
export async function getRawIndexData(): Promise<RawIndexData> {
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<any, number>();
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<any, number>();
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<QueryResult>(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<any>): Vector<any> {
const values = new Array(keep.length);
for (let i = 0; i < keep.length; i++) {
values[i] = raw.get(keep[i]);
}
return new ArrayVector(values);
}

View File

@ -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<QueryResponse> {
if (query.facet?.length) {
throw 'facets not supported!';
}
return doSearchQuery(query);
}
async list(location: string): Promise<QueryResponse> {
return doSearchQuery({ query: `list:${location ?? ''}` });
}
async tags(query: SearchQuery): Promise<TermCount[]> {
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<QueryResponse> {
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<DashboardQueryResult>(first);
return {
totalRows: meta.count ?? first.length,
view,
loadMoreItems: async (startIndex: number, stopIndex: number): Promise<void> => {
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;
}

View File

@ -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<number>;
url?: Vector<string>;
uid?: Vector<string>;
name?: Vector<string>;
folder?: Vector<number>;
description?: Vector<string>;
dashboardID?: Vector<number>;
location?: Vector<LocationInfo[]>;
datasource?: Vector<DataSourceRef[]>;
type?: Vector<string>;
tags?: Vector<string[]>; // 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<SearchResultKind, InputDoc>();
data: RawIndexData = {};
index?: MiniSearch<InputDoc>;
constructor(private supplier: rawIndexSupplier = getRawIndexData) {
// waits for first request to load data
}
private async initIndex() {
const data = await this.supplier();
const searcher = new MiniSearch<InputDoc>({
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<SearchResultKind, InputDoc>();
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<number, number>();
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<number, number>();
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<QueryResponse> {
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;
}

View File

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

View File

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

View File

@ -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<string, LocationInfo>;
}
export interface QueryResponse {
body: DataFrame;
view: DataFrameView<DashboardQueryResult>;
/** Supports lazy loading. This will mutate the `view` object above, adding rows as needed */
loadMoreItems: (startIndex: number, stopIndex: number) => Promise<void>;
/** 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<QueryResponse>;
search: (query: SearchQuery) => Promise<QueryResponse>;
list: (location: string) => Promise<QueryResponse>;
tags: (query: SearchQuery) => Promise<TermCount[]>;
}

View File

@ -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<Props, State> {
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 (
<InlineFieldRow>
<InlineField label="Query" grow={true} labelWidth={labelWidth}>
<Input
placeholder="Everything"
defaultValue={query ?? ''}
onKeyDown={this.handleSearchEnterKey}
onBlur={this.handleSearchBlur}
spellCheck={false}
/>
</InlineField>
</InlineFieldRow>
<>
<Alert title="Grafana Search" severity="info">
This interface to the grafana search API is experimental, and subject to change at any time without notice
</Alert>
<InlineFieldRow>
<InlineField label="Query" grow={true} labelWidth={labelWidth}>
<Input
placeholder="Everything"
defaultValue={query.query ?? ''}
onKeyDown={this.handleSearchEnterKey}
onBlur={this.handleSearchBlur}
spellCheck={false}
/>
</InlineField>
</InlineFieldRow>
<CodeEditor
height={300}
language="json"
value={json}
onBlur={this.onSaveSearchJSON}
onSave={this.onSaveSearchJSON}
showMiniMap={true}
showLineNumbers={true}
/>
</>
);
}

View File

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

View File

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