mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: use bluge index for frontend search (playground) (#48847)
This commit is contained in:
parent
d31d300ce1
commit
3a32a73459
@ -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",
|
||||
|
@ -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};
|
||||
|
@ -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> = ({
|
||||
|
@ -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;
|
||||
|
177
public/app/features/search/page/components/FolderSection.tsx
Normal file
177
public/app/features/search/page/components/FolderSection.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
});
|
127
public/app/features/search/page/components/FolderView.tsx
Normal file
127
public/app/features/search/page/components/FolderView.tsx
Normal 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};
|
||||
`,
|
||||
};
|
||||
};
|
104
public/app/features/search/page/components/SearchResultsGrid.tsx
Normal file
104
public/app/features/search/page/components/SearchResultsGrid.tsx
Normal 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;
|
||||
}
|
||||
`,
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
133
public/app/features/search/service/bluge.ts
Normal file
133
public/app/features/search/service/bluge.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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!;
|
||||
}
|
||||
|
@ -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[]>;
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
32
yarn.lock
32
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"
|
||||
|
Loading…
Reference in New Issue
Block a user