import { cx } from '@emotion/css'; import { intervalToDuration } from 'date-fns'; import Skeleton from 'react-loading-skeleton'; import { DisplayProcessor, Field, FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, } from '@grafana/data'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { Checkbox, Icon, IconName, TagList, Text, Tooltip } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; import { formatDate, formatDuration } from 'app/core/internationalization/dates'; import { PluginIconName } from 'app/features/plugins/admin/types'; import { ShowModalReactEvent } from 'app/types/events'; import { QueryResponse, SearchResultMeta } from '../../service/types'; import { getIconForKind } from '../../service/utils'; import { SelectionChecker, SelectionToggle } from '../selection'; import { ExplainScorePopup } from './ExplainScorePopup'; import { TableColumn } from './SearchResultsTable'; const TYPE_COLUMN_WIDTH = 175; const DURATION_COLUMN_WIDTH = 200; const DATASOURCE_COLUMN_WIDTH = 200; export const generateColumns = ( response: QueryResponse, availableWidth: number, selection: SelectionChecker | undefined, selectionToggle: SelectionToggle | undefined, clearSelection: () => void, styles: { [key: string]: string }, onTagSelected: (tag: string) => void, onDatasourceChange?: (datasource?: string) => void, showingEverything?: boolean ): TableColumn[] => { const columns: TableColumn[] = []; const access = response.view.fields; const uidField = access.uid; const kindField = access.kind; let sortFieldWith = 0; const sortField: Field = access[response.view.dataFrame.meta?.custom?.sortBy]; if (sortField) { sortFieldWith = 175; if (sortField.type === FieldType.time) { sortFieldWith += 25; } availableWidth -= sortFieldWith; // pre-allocate the space for the last column } if (access.explain && access.score) { availableWidth -= 100; // pre-allocate the space for the last column } let width = 50; if (selection && selectionToggle) { width = 0; columns.push({ id: `column-checkbox`, width, Header: () => { const { view } = response; const hasSelection = selection('*', '*'); const allSelected = view.every((item) => selection(item.kind, item.uid)); return ( { if (hasSelection) { clearSelection(); } else { for (let i = 0; i < view.length; i++) { const item = view.get(i); selectionToggle(item.kind, item.uid); } } }} /> ); }, Cell: (p) => { const uid = uidField.values[p.row.index]; const kind = kindField ? kindField.values[p.row.index] : 'dashboard'; // HACK for now const selected = selection(kind, uid); const hasUID = uid != null; // Panels don't have UID! Likely should not be shown on pages with manage options const { key, ...cellProps } = p.cellProps; return (
{ selectionToggle(kind, uid); }} />
); }, field: uidField, }); availableWidth -= width; } // Name column width = Math.max(availableWidth * 0.2, 300); columns.push({ Cell: (p) => { let classNames = cx(styles.nameCellStyle); let name = access.name.values[p.row.index]; const isDeleted = access.isDeleted?.values[p.row.index]; if (!name?.length) { const loading = p.row.index >= response.view.dataFrame.length; name = loading ? 'Loading...' : 'Missing title'; // normal for panels classNames += ' ' + styles.missingTitleText; } const { key, ...cellProps } = p.cellProps; return (
{!response.isItemLoaded(p.row.index) ? ( ) : isDeleted ? ( {name} ) : ( {name} )}
); }, id: `column-name`, field: access.name!, Header: () =>
{t('search.results-table.name-header', 'Name')}
, width, }); availableWidth -= width; const showDeletedRemaining = response.view.fields.permanentlyDeleteDate && hasValue(response.view.fields.permanentlyDeleteDate); if (showDeletedRemaining && access.permanentlyDeleteDate) { width = DURATION_COLUMN_WIDTH; columns.push(makeDeletedRemainingColumn(response, access.permanentlyDeleteDate, width, styles)); availableWidth -= width; } else { width = TYPE_COLUMN_WIDTH; columns.push(makeTypeColumn(response, access.kind, access.panel_type, width, styles)); availableWidth -= width; } // Show datasources if we have any if (access.ds_uid && onDatasourceChange) { width = Math.min(availableWidth / 2.5, DATASOURCE_COLUMN_WIDTH); columns.push( makeDataSourceColumn( access.ds_uid, width, styles.typeIcon, styles.datasourceItem, styles.invalidDatasourceItem, onDatasourceChange ) ); availableWidth -= width; } const showTags = !showingEverything || hasValue(response.view.fields.tags); const meta = response.view.dataFrame.meta?.custom as SearchResultMeta; if (meta?.locationInfo && availableWidth > 0) { width = showTags ? Math.max(availableWidth / 1.75, 300) : availableWidth; availableWidth -= width; columns.push({ Cell: (p) => { const parts = (access.location?.values[p.row.index] ?? '').split('/'); const { key, ...cellProps } = p.cellProps; return (
{!response.isItemLoaded(p.row.index) ? ( ) : (
{parts.map((p) => { let info = meta.locationInfo[p]; if (!info && p === 'general') { info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' }; } if (info) { const content = ( <> {info.name} ); if (info.url) { return ( {content} ); } return (
{content}
); } return {p}; })}
)}
); }, id: `column-location`, field: access.location ?? access.url, Header: t('search.results-table.location-header', 'Location'), width, }); } if (availableWidth > 0 && showTags) { columns.push(makeTagsColumn(response, access.tags, availableWidth, styles, onTagSelected)); } if (sortField && sortFieldWith) { const disp = sortField.display ?? getDisplayProcessor({ field: sortField, theme: config.theme2 }); columns.push({ Header: getFieldDisplayName(sortField), Cell: (p) => { const { key, ...cellProps } = p.cellProps; return (
{getDisplayValue({ sortField, getDisplay: disp, index: p.row.index, kind: access.kind, })}
); }, id: `column-sort-field`, field: sortField, width: sortFieldWith, }); } if (access.explain && access.score) { const vals = access.score.values; const showExplainPopup = (row: number) => { appEvents.publish( new ShowModalReactEvent({ component: ExplainScorePopup, props: { name: access.name.values[row], explain: access.explain.values[row], frame: response.view.dataFrame, row: row, }, }) ); }; columns.push({ Header: () =>
Score
, Cell: (p) => { const { key, ...cellProps } = p.cellProps; return ( // TODO: fix keyboard a11y // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
showExplainPopup(p.row.index)} > {vals[p.row.index]}
); }, id: `column-score-field`, field: access.score, width: 100, }); } return columns; }; function hasValue(f: Field): boolean { for (let i = 0; i < f.values.length; i++) { if (f.values[i] != null) { return true; } } return false; } function makeDataSourceColumn( field: Field, width: number, iconClass: string, datasourceItemClass: string, invalidDatasourceItemClass: string, onDatasourceChange: (datasource?: string) => void ): TableColumn { const srv = getDataSourceSrv(); return { id: `column-datasource`, field, Header: t('search.results-table.datasource-header', 'Data source'), Cell: (p) => { const dslist = field.values[p.row.index]; if (!dslist?.length) { return null; } const { key, ...cellProps } = p.cellProps; return (
{dslist.map((v, i) => { const settings = srv.getInstanceSettings(v); const icon = settings?.meta?.info?.logos?.small; if (icon) { return ( // TODO: fix keyboard a11y // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions { e.stopPropagation(); e.preventDefault(); onDatasourceChange(settings.uid); }} > {settings.name} ); } return ( {v} ); })}
); }, width, }; } function makeDeletedRemainingColumn( response: QueryResponse, deletedField: Field, width: number, styles: Record ): TableColumn { return { id: 'column-delete-age', field: deletedField, width, Header: t('search.results-table.deleted-remaining-header', 'Time remaining'), Cell: (p) => { const i = p.row.index; const deletedDate = deletedField.values[i]; const { key, ...cellProps } = p.cellProps; if (!deletedDate || !response.isItemLoaded(p.row.index)) { return (
); } const duration = calcCoarseDuration(new Date(), deletedDate); const isDeletingSoon = !Object.values(duration).some((v) => v > 0); const formatted = isDeletingSoon ? t('search.results-table.deleted-less-than-1-min', '< 1 min') : formatDuration(duration, { style: 'long' }); return (
{formatted}
); }, }; } function makeTypeColumn( response: QueryResponse, kindField: Field, typeField: Field, width: number, styles: Record ): TableColumn { return { id: `column-type`, field: kindField ?? typeField, Header: t('search.results-table.type-header', 'Type'), Cell: (p) => { const i = p.row.index; const kind = kindField?.values[i] ?? 'dashboard'; let icon: IconName = 'apps'; let txt = 'Dashboard'; if (kind) { txt = kind; switch (txt) { case 'dashboard': txt = t('search.results-table.type-dashboard', 'Dashboard'); break; case 'folder': icon = 'folder'; txt = t('search.results-table.type-folder', 'Folder'); break; case 'panel': icon = `${PluginIconName.panel}`; const type = typeField.values[i]; if (type) { txt = type; const info = config.panels[txt]; if (info?.name) { txt = info.name; } else { switch (type) { case 'row': txt = 'Row'; icon = `bars`; break; case 'singlestat': // auto-migration txt = 'Singlestat'; break; default: icon = `question-circle`; // plugin not found } } } break; } } const { key, ...cellProps } = p.cellProps; return (
{!response.isItemLoaded(p.row.index) ? ( ) : ( <> {txt} )}
); }, width, }; } function makeTagsColumn( response: QueryResponse, field: Field, width: number, styles: Record, onTagSelected: (tag: string) => void ): TableColumn { return { Cell: (p) => { const tags = field.values[p.row.index]; const { key, ...cellProps } = p.cellProps; return (
{!response.isItemLoaded(p.row.index) ? ( ) : ( <>{tags ? : null} )}
); }, id: `column-tags`, field: field, Header: t('search.results-table.tags-header', 'Tags'), width, }; } function getDisplayValue({ kind, sortField, index, getDisplay, }: { kind: Field; sortField: Field; index: number; getDisplay: DisplayProcessor; }) { const value = sortField.values[index]; if (['folder', 'panel'].includes(kind.values[index]) && value === 0) { return '-'; } return formattedValueToString(getDisplay(value)); } /** * Calculates the rough duration between two dates, keeping only the most significant unit */ function calcCoarseDuration(start: Date, end: Date) { let { years = 0, months = 0, days = 0, hours = 0, minutes = 0 } = intervalToDuration({ start, end }); if (years > 0) { return { years }; } else if (months > 0) { return { months }; } else if (days > 0) { return { days }; } else if (hours > 0) { return { hours }; } return { minutes }; }