diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts
index f1ad205615a..d8fa3ab3c27 100644
--- a/public/app/features/explore/TraceView/createSpanLink.test.ts
+++ b/public/app/features/explore/TraceView/createSpanLink.test.ts
@@ -863,6 +863,133 @@ describe('createSpanLinkFactory', () => {
expect(links?.logLinks).toBeUndefined();
});
});
+
+ describe('should return falconLogScale link', () => {
+ const falconLogScaleUID = 'falconLogScaleUID';
+
+ beforeAll(() => {
+ setDataSourceSrv({
+ getInstanceSettings() {
+ return {
+ uid: falconLogScaleUID,
+ name: 'FalconLogScale',
+ type: 'grafana-falconlogscale-datasource',
+ } as unknown as DataSourceInstanceSettings;
+ },
+ } as unknown as DataSourceSrv);
+
+ setLinkSrv(new LinkSrv());
+ setTemplateSrv(new TemplateSrv());
+ });
+
+ it('the `lsql` keyword is used in the link', () => {
+ const createLink = setupSpanLinkFactory({
+ datasourceUid: falconLogScaleUID,
+ });
+ const links = createLink!(createTraceSpan());
+
+ const linkDef = links?.logLinks?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef!.href).toContain(`${encodeURIComponent('datasource":"falconLogScaleUID","queries":[{"lsql"')}`);
+ });
+
+ it('formats query correctly if filterByTraceID and or filterBySpanID is true', () => {
+ const createLink = setupSpanLinkFactory({
+ datasourceUid: falconLogScaleUID,
+ filterByTraceID: true,
+ filterBySpanID: true,
+ });
+
+ expect(createLink).toBeDefined();
+ const links = createLink!(createTraceSpan());
+
+ const linkDef = links?.logLinks?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"cluster=\\"cluster1\\" OR hostname=\\"hostname1\\" or \\"7946b05c2e2e4e5a\\" or \\"6605c7b08e715d6c\\"","refId":""}]}'
+ )}`
+ );
+ });
+
+ it('should format one tag correctly', () => {
+ const createLink = setupSpanLinkFactory({
+ tags: [{ key: 'ip' }],
+ });
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [{ key: 'ip', value: '192.168.0.1' }],
+ },
+ })
+ );
+
+ const linkDef = links?.logLinks?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"ip=\\"192.168.0.1\\"","refId":""}]}'
+ )}`
+ );
+ });
+
+ it('should format multiple tags correctly', () => {
+ const createLink = setupSpanLinkFactory({
+ tags: [{ key: 'ip' }, { key: 'hostname' }],
+ });
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'hostname', value: 'hostname1' },
+ { key: 'ip', value: '192.168.0.1' },
+ ],
+ },
+ })
+ );
+
+ const linkDef = links?.logLinks?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"hostname=\\"hostname1\\" OR ip=\\"192.168.0.1\\"","refId":""}]}'
+ )}`
+ );
+ });
+
+ it('handles renamed tags', () => {
+ const createLink = setupSpanLinkFactory({
+ tags: [
+ { key: 'service.name', value: 'service' },
+ { key: 'k8s.pod.name', value: 'pod' },
+ ],
+ });
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'service.name', value: 'serviceName' },
+ { key: 'k8s.pod.name', value: 'podName' },
+ ],
+ },
+ })
+ );
+
+ const linkDef = links?.logLinks?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"service=\\"serviceName\\" OR pod=\\"podName\\"","refId":""}]}'
+ )}`
+ );
+ });
+ });
});
function setupSpanLinkFactory(options: Partial
= {}, datasourceUid = 'lokiUid') {
diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx
index 3edbdf50e31..d529243b4ef 100644
--- a/public/app/features/explore/TraceView/createSpanLink.tsx
+++ b/public/app/features/explore/TraceView/createSpanLink.tsx
@@ -164,6 +164,9 @@ function legacyCreateSpanLinkFactory(
tags = getFormattedTags(span, tagsToUse, { labelValueSign: ':', joinBy: ' AND ' });
query = getQueryForElasticsearchOrOpensearch(span, traceToLogsOptions, tags, customQuery);
break;
+ case 'grafana-falconlogscale-datasource':
+ tags = getFormattedTags(span, tagsToUse, { joinBy: ' OR ' });
+ query = getQueryForFalconLogScale(span, traceToLogsOptions, tags, customQuery);
}
// query can be false in case the simple UI tag mapping is used but none of them are present in the span.
@@ -405,6 +408,35 @@ function getQueryForSplunk(span: TraceSpan, options: TraceToLogsOptionsV2, tags:
};
}
+function getQueryForFalconLogScale(span: TraceSpan, options: TraceToLogsOptionsV2, tags: string, customQuery?: string) {
+ const { filterByTraceID, filterBySpanID } = options;
+
+ if (customQuery) {
+ return {
+ lsql: customQuery,
+ refId: '',
+ };
+ }
+
+ if (!tags) {
+ return undefined;
+ }
+
+ let lsql = '${__tags}';
+ if (filterByTraceID && span.traceID) {
+ lsql += ' or "${__span.traceId}"';
+ }
+
+ if (filterBySpanID && span.spanID) {
+ lsql += ' or "${__span.spanId}"';
+ }
+
+ return {
+ lsql,
+ refId: '',
+ };
+}
+
/**
* Creates a string representing all the tags already formatted for use in the query. The tags are filtered so that
* only intersection of tags that exist in a span and tags that you want are serialized into the string.
diff --git a/public/app/features/logs/components/LogRowContext.tsx b/public/app/features/logs/components/LogRowContext.tsx
index 66eb5b60670..33d28b1b596 100644
--- a/public/app/features/logs/components/LogRowContext.tsx
+++ b/public/app/features/logs/components/LogRowContext.tsx
@@ -11,7 +11,16 @@ import {
DataSourceWithLogsContextSupport,
} from '@grafana/data';
import { config } from '@grafana/runtime';
-import { Alert, Button, ClickOutsideWrapper, CustomScrollbar, IconButton, List, useStyles2 } from '@grafana/ui';
+import {
+ Alert,
+ Button,
+ ClickOutsideWrapper,
+ CustomScrollbar,
+ IconButton,
+ List,
+ useStyles2,
+ useTheme2,
+} from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi';
import { HasMoreContextRows, LogRowContextQueryErrors, LogRowContextRows } from './LogRowContextProvider';
@@ -98,7 +107,7 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean,
background: ${theme.colors.background.canvas};
`,
top: css`
- border-radius: 0 0 ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)};
+ border-radius: 0 0 ${theme.shape.borderRadius()} ${theme.shape.borderRadius()};
box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black};
clip-path: inset(0px -${theme.spacing(1.25)} -${theme.spacing(1.25)} -${theme.spacing(1.25)});
`,
@@ -110,7 +119,7 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean,
height: ${headerHeight}px;
background: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.background.secondary};
- border-radius: ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)} 0 0;
+ border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0;
box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black};
clip-path: inset(-${theme.spacing(1.25)} -${theme.spacing(1.25)} 0px -${theme.spacing(1.25)});
font-family: ${theme.typography.fontFamily};
@@ -158,6 +167,7 @@ interface LogRowContextGroupHeaderProps {
logsSortOrder?: LogsSortOrder | null;
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
runContextQuery?: () => void;
+ onHeightChange?: (height: number) => void;
}
interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps {
rows: Array;
@@ -175,14 +185,12 @@ const LogRowContextGroupHeader: React.FunctionComponent {
const [height, setHeight] = useState(0);
const datasourceUiRef = React.createRef();
- const {
- datasourceUi: dsUi,
- header,
- headerButton,
- } = useStyles2((theme) => getLogRowContextStyles(theme, undefined, height));
+ const theme = useTheme2();
+ const { datasourceUi, header, headerButton } = getLogRowContextStyles(theme, undefined, height);
// determine the position in time for this LogGroup by taking the ordering of
// logs and position of the component itself into account.
@@ -204,9 +212,12 @@ const LogRowContextGroupHeader: React.FunctionComponent {
for (let entry of entries) {
setHeight(entry.contentRect.height);
+ if (onHeightChange) {
+ onHeightChange(entry.contentRect.height);
+ }
}
}),
- []
+ [onHeightChange]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -226,7 +237,7 @@ const LogRowContextGroupHeader: React.FunctionComponent
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
-
+
{getLogRowContextUi(row, runContextQuery)}
)}
@@ -260,8 +271,11 @@ export const LogRowContextGroup: React.FunctionComponent
{
- const { commonStyles, logs, bottomContext } = useStyles2(getLogRowContextStyles);
+ const [height, setHeight] = useState(0);
+ const theme = useTheme2();
+ const { commonStyles, logs, bottomContext, afterContext } = getLogRowContextStyles(theme, undefined, height);
const [scrollTop, setScrollTop] = useState(0);
const [scrollHeight, setScrollHeight] = useState(0);
@@ -306,6 +320,13 @@ export const LogRowContextGroup: React.FunctionComponent {
+ setHeight(height);
+ if (onHeightChange) {
+ onHeightChange(height);
+ }
+ };
+
const headerProps = {
row,
rows,
@@ -318,9 +339,11 @@ export const LogRowContextGroup: React.FunctionComponent
+
{/* When displaying "after" context */}
- {shouldScrollToBottom && !error &&
}
+ {shouldScrollToBottom && !error &&
}
@@ -373,8 +396,9 @@ export const LogRowContext: React.FunctionComponent
= ({
document.removeEventListener('keydown', handleEscKeyDown, false);
};
}, [onOutsideClick, row]);
- const { afterContext, beforeContext, title, top, actions, width } = useStyles2((theme) =>
- getLogRowContextStyles(theme, wrapLogMessage)
+ const [height, setHeight] = useState(0);
+ const { beforeContext, title, top, actions, width } = useStyles2((theme) =>
+ getLogRowContextStyles(theme, wrapLogMessage, height)
);
const handleOutsideClick = useCallback(() => onOutsideClick('close_outside_click'), [onOutsideClick]);
@@ -390,7 +414,7 @@ export const LogRowContext: React.FunctionComponent = ({
rows={context.after}
error={errors && errors.after}
row={row}
- className={cx(afterContext, top, width)}
+ className={cx(top, width)}
shouldScrollToBottom
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false}
onLoadMoreContext={onLoadMoreContext}
@@ -398,6 +422,7 @@ export const LogRowContext: React.FunctionComponent = ({
logsSortOrder={logsSortOrder}
getLogRowContextUi={getLogRowContextUi}
runContextQuery={runContextQuery}
+ onHeightChange={setHeight}
/>
)}
diff --git a/public/app/features/plugins/admin/components/PluginList.tsx b/public/app/features/plugins/admin/components/PluginList.tsx
index 9e12fbf3824..afa87074e14 100644
--- a/public/app/features/plugins/admin/components/PluginList.tsx
+++ b/public/app/features/plugins/admin/components/PluginList.tsx
@@ -18,8 +18,8 @@ interface Props {
export const PluginList = ({ plugins, displayMode }: Props) => {
const isList = displayMode === PluginListDisplayMode.List;
const styles = useStyles2(getStyles);
- const location = useLocation();
- const pathName = config.appSubUrl + location.pathname;
+ const { pathname } = useLocation();
+ const pathName = config.appSubUrl + (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
return (
diff --git a/public/app/features/plugins/extensions/placements.ts b/public/app/features/plugins/extensions/placements.ts
deleted file mode 100644
index 27af2a0bc78..00000000000
--- a/public/app/features/plugins/extensions/placements.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export enum GrafanaExtensions {
- DashboardPanelMenu = 'grafana/dashboard/panel/menu',
-}
diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx
index 7b37837b294..c3d5d96882c 100644
--- a/public/app/features/search/page/components/SearchResultsTable.tsx
+++ b/public/app/features/search/page/components/SearchResultsTable.tsx
@@ -7,9 +7,10 @@ import InfiniteLoader from 'react-window-infinite-loader';
import { Observable } from 'rxjs';
import { Field, GrafanaTheme2 } from '@grafana/data';
-import { useStyles2 } from '@grafana/ui';
+import { TableCellHeight } from '@grafana/schema';
+import { useStyles2, useTheme2 } from '@grafana/ui';
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
-import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
+import { useTableStyles } from '@grafana/ui/src/components/Table/styles';
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
import { QueryResponse } from '../../service';
@@ -51,7 +52,7 @@ export const SearchResultsTable = React.memo(
}: SearchResultsProps) => {
const styles = useStyles2(getStyles);
const columnStyles = useStyles2(getColumnStyles);
- const tableStyles = useStyles2(getTableStyles);
+ const tableStyles = useTableStyles(useTheme2(), TableCellHeight.Md);
const infiniteLoaderRef = useRef
(null);
const [listEl, setListEl] = useState(null);
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);
diff --git a/public/app/features/search/service/frontend.ts b/public/app/features/search/service/frontend.ts
index b11fe34d77d..51b5af4c657 100644
--- a/public/app/features/search/service/frontend.ts
+++ b/public/app/features/search/service/frontend.ts
@@ -130,7 +130,7 @@ class FullResultCache {
}
}
// > 1000 matches (unranked)
- else {
+ else if (idxs) {
for (let i = 0; i < idxs.length; i++) {
let haystackIdx = idxs[i];
dst.push(src[haystackIdx]);
diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts
index c047c59ab14..8716a48a20a 100644
--- a/public/app/features/transformers/standardTransformers.ts
+++ b/public/app/features/transformers/standardTransformers.ts
@@ -1,4 +1,5 @@
import { TransformerRegistryItem } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor';
import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor';
@@ -27,6 +28,7 @@ import { partitionByValuesTransformRegistryItem } from './partitionByValues/Part
import { prepareTimeseriesTransformerRegistryItem } from './prepareTimeSeries/PrepareTimeSeriesEditor';
import { rowsToFieldsTransformRegistryItem } from './rowsToFields/RowsToFieldsTransformerEditor';
import { spatialTransformRegistryItem } from './spatial/SpatialTransformerEditor';
+import { timeSeriesTableTransformRegistryItem } from './timeSeriesTable/TimeSeriesTableTransformEditor';
export const getStandardTransformers = (): Array> => {
return [
@@ -57,5 +59,6 @@ export const getStandardTransformers = (): Array> =
limitTransformRegistryItem,
joinByLabelsTransformRegistryItem,
partitionByValuesTransformRegistryItem,
+ ...(config.featureToggles.timeSeriesTable ? [timeSeriesTableTransformRegistryItem] : []),
];
};
diff --git a/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
new file mode 100644
index 00000000000..eb01f21de1b
--- /dev/null
+++ b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { PluginState, TransformerRegistryItem, TransformerUIProps } from '@grafana/data';
+
+import { timeSeriesTableTransformer, TimeSeriesTableTransformerOptions } from './timeSeriesTableTransformer';
+
+export interface Props extends TransformerUIProps<{}> {}
+
+export function TimeSeriesTableTransformEditor({ input, options, onChange }: Props) {
+ if (input.length === 0) {
+ return null;
+ }
+
+ return ;
+}
+
+export const timeSeriesTableTransformRegistryItem: TransformerRegistryItem = {
+ id: timeSeriesTableTransformer.id,
+ editor: TimeSeriesTableTransformEditor,
+ transformation: timeSeriesTableTransformer,
+ name: timeSeriesTableTransformer.name,
+ description: timeSeriesTableTransformer.description,
+ state: PluginState.beta,
+ help: ``,
+};
diff --git a/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.test.ts b/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.test.ts
new file mode 100644
index 00000000000..952fcdf0d9b
--- /dev/null
+++ b/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.test.ts
@@ -0,0 +1,104 @@
+import { toDataFrame, FieldType, Labels, DataFrame, Field } from '@grafana/data';
+
+import { timeSeriesToTableTransform } from './timeSeriesTableTransformer';
+
+describe('timeSeriesTableTransformer', () => {
+ it('Will transform a single query', () => {
+ const series = [
+ getTimeSeries('A', { instance: 'A', pod: 'B' }),
+ getTimeSeries('A', { instance: 'A', pod: 'C' }),
+ getTimeSeries('A', { instance: 'A', pod: 'D' }),
+ ];
+
+ const results = timeSeriesToTableTransform({}, series);
+ expect(results).toHaveLength(1);
+ const result = results[0];
+ expect(result.refId).toBe('A');
+ expect(result.fields).toHaveLength(3);
+ expect(result.fields[0].values.toArray()).toEqual(['A', 'A', 'A']);
+ expect(result.fields[1].values.toArray()).toEqual(['B', 'C', 'D']);
+ assertDataFrameField(result.fields[2], series);
+ });
+
+ it('Will pass through non time series frames', () => {
+ const series = [
+ getTable('B', ['foo', 'bar']),
+ getTimeSeries('A', { instance: 'A', pod: 'B' }),
+ getTimeSeries('A', { instance: 'A', pod: 'C' }),
+ getTable('C', ['bar', 'baz', 'bad']),
+ ];
+
+ const results = timeSeriesToTableTransform({}, series);
+ expect(results).toHaveLength(3);
+ expect(results[0]).toEqual(series[0]);
+ expect(results[1].refId).toBe('A');
+ expect(results[1].fields).toHaveLength(3);
+ expect(results[1].fields[0].values.toArray()).toEqual(['A', 'A']);
+ expect(results[1].fields[1].values.toArray()).toEqual(['B', 'C']);
+ expect(results[2]).toEqual(series[3]);
+ });
+
+ it('Will group by refId', () => {
+ const series = [
+ getTimeSeries('A', { instance: 'A', pod: 'B' }),
+ getTimeSeries('A', { instance: 'A', pod: 'C' }),
+ getTimeSeries('A', { instance: 'A', pod: 'D' }),
+ getTimeSeries('B', { instance: 'B', pod: 'F', cluster: 'A' }),
+ getTimeSeries('B', { instance: 'B', pod: 'G', cluster: 'B' }),
+ ];
+
+ const results = timeSeriesToTableTransform({}, series);
+ expect(results).toHaveLength(2);
+ expect(results[0].refId).toBe('A');
+ expect(results[0].fields).toHaveLength(3);
+ expect(results[0].fields[0].values.toArray()).toEqual(['A', 'A', 'A']);
+ expect(results[0].fields[1].values.toArray()).toEqual(['B', 'C', 'D']);
+ assertDataFrameField(results[0].fields[2], series.slice(0, 3));
+ expect(results[1].refId).toBe('B');
+ expect(results[1].fields).toHaveLength(4);
+ expect(results[1].fields[0].values.toArray()).toEqual(['B', 'B']);
+ expect(results[1].fields[1].values.toArray()).toEqual(['F', 'G']);
+ expect(results[1].fields[2].values.toArray()).toEqual(['A', 'B']);
+ assertDataFrameField(results[1].fields[3], series.slice(3, 5));
+ });
+});
+
+function assertFieldsEqual(field1: Field, field2: Field) {
+ expect(field1.type).toEqual(field2.type);
+ expect(field1.name).toEqual(field2.name);
+ expect(field1.values.toArray()).toEqual(field2.values.toArray());
+ expect(field1.labels ?? {}).toEqual(field2.labels ?? {});
+}
+
+function assertDataFrameField(field: Field, matchesFrames: DataFrame[]) {
+ const frames: DataFrame[] = field.values.toArray();
+ expect(frames).toHaveLength(matchesFrames.length);
+ frames.forEach((frame, idx) => {
+ const matchingFrame = matchesFrames[idx];
+ expect(frame.fields).toHaveLength(matchingFrame.fields.length);
+ frame.fields.forEach((field, fidx) => assertFieldsEqual(field, matchingFrame.fields[fidx]));
+ });
+}
+
+function getTimeSeries(refId: string, labels: Labels) {
+ return toDataFrame({
+ refId,
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [10] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [10],
+ labels,
+ },
+ ],
+ });
+}
+
+function getTable(refId: string, fields: string[]) {
+ return toDataFrame({
+ refId,
+ fields: fields.map((f) => ({ name: f, type: FieldType.string, values: ['value'] })),
+ labels: {},
+ });
+}
diff --git a/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.ts b/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.ts
new file mode 100644
index 00000000000..d3e6b68a12c
--- /dev/null
+++ b/public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.ts
@@ -0,0 +1,127 @@
+import { map } from 'rxjs/operators';
+
+import {
+ ArrayVector,
+ DataFrame,
+ DataTransformerID,
+ DataTransformerInfo,
+ Field,
+ FieldType,
+ MutableDataFrame,
+ isTimeSeriesFrame,
+} from '@grafana/data';
+
+export interface TimeSeriesTableTransformerOptions {}
+
+export const timeSeriesTableTransformer: DataTransformerInfo = {
+ id: DataTransformerID.timeSeriesTable,
+ name: 'Time series to table transform',
+ description: 'Time series to table rows',
+ defaultOptions: {},
+
+ operator: (options) => (source) =>
+ source.pipe(
+ map((data) => {
+ return timeSeriesToTableTransform(options, data);
+ })
+ ),
+};
+
+/**
+ * Converts time series frames to table frames for use with sparkline chart type.
+ *
+ * @remarks
+ * For each refId (queryName) convert all time series frames into a single table frame, adding each series
+ * as values of a "Trend" frame field. This allows "Trend" to be rendered as area chart type.
+ * Any non time series frames are returned as is.
+ *
+ * @param options - Transform options, currently not used
+ * @param data - Array of data frames to transform
+ * @returns Array of transformed data frames
+ *
+ * @alpha
+ */
+export function timeSeriesToTableTransform(options: TimeSeriesTableTransformerOptions, data: DataFrame[]): DataFrame[] {
+ // initialize fields from labels for each refId
+ const refId2LabelFields = getLabelFields(data);
+
+ const refId2frameField: Record> = {};
+
+ const result: DataFrame[] = [];
+
+ for (const frame of data) {
+ if (!isTimeSeriesFrame(frame)) {
+ result.push(frame);
+ continue;
+ }
+
+ const refId = frame.refId ?? '';
+
+ const labelFields = refId2LabelFields[refId] ?? {};
+ // initialize a new frame for this refId with fields per label and a Trend frame field, if it doesn't exist yet
+ let frameField = refId2frameField[refId];
+ if (!frameField) {
+ frameField = {
+ name: 'Trend' + (refId && Object.keys(refId2LabelFields).length > 1 ? ` #${refId}` : ''),
+ type: FieldType.frame,
+ config: {},
+ values: new ArrayVector(),
+ };
+ refId2frameField[refId] = frameField;
+ const table = new MutableDataFrame();
+ for (const label of Object.values(labelFields)) {
+ table.addField(label);
+ }
+ table.addField(frameField);
+ table.refId = refId;
+ result.push(table);
+ }
+
+ // add values to each label based field of this frame
+ const labels = frame.fields[1].labels;
+ for (const labelKey of Object.keys(labelFields)) {
+ const labelValue = labels?.[labelKey] ?? null;
+ labelFields[labelKey].values.add(labelValue);
+ }
+
+ frameField.values.add(frame);
+ }
+ return result;
+}
+
+// For each refId, initialize a field for each label name
+function getLabelFields(frames: DataFrame[]): Record>> {
+ // refId -> label name -> field
+ const labelFields: Record>> = {};
+
+ for (const frame of frames) {
+ if (!isTimeSeriesFrame(frame)) {
+ continue;
+ }
+
+ const refId = frame.refId ?? '';
+
+ if (!labelFields[refId]) {
+ labelFields[refId] = {};
+ }
+
+ for (const field of frame.fields) {
+ if (!field.labels) {
+ continue;
+ }
+
+ for (const labelName of Object.keys(field.labels)) {
+ if (!labelFields[refId][labelName]) {
+ labelFields[refId][labelName] = {
+ name: labelName,
+ type: FieldType.string,
+ config: {},
+ values: new ArrayVector(),
+ };
+ }
+ }
+ }
+ }
+
+ return labelFields;
+}
diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts
index 5d6c728f470..376aa817e5b 100644
--- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts
+++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts
@@ -133,6 +133,7 @@ export class CloudWatchLogsLanguageProvider extends LanguageProvider {
const interpolatedLogGroups = interpolateStringArrayUsingSingleOrMultiValuedVariable(
getTemplateSrv(),
logGroups.map((lg) => lg.name),
+ {},
'text'
);
const results = await Promise.all(
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts
index 79e379a79ad..f1770514826 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts
@@ -195,7 +195,7 @@ describe('CloudWatchLogsQueryRunner', () => {
const legacyLogGroupNamesQuery: CloudWatchLogsQuery = {
queryMode: 'Logs',
- logGroupNames: ['group-A', 'templatedGroup-1', logGroupNamesVariable.name],
+ logGroupNames: ['group-A', 'templatedGroup-1', `$${logGroupNamesVariable.name}`],
hide: false,
id: '',
region: 'us-east-2',
@@ -207,7 +207,7 @@ describe('CloudWatchLogsQueryRunner', () => {
queryMode: 'Logs',
logGroups: [
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:group-A:*', name: 'group-A' },
- { arn: logGroupNamesVariable.name, name: logGroupNamesVariable.name },
+ { arn: `$${logGroupNamesVariable.name}`, name: logGroupNamesVariable.name },
],
hide: false,
id: '',
@@ -218,7 +218,7 @@ describe('CloudWatchLogsQueryRunner', () => {
const logsScopedVarQuery: CloudWatchLogsQuery = {
queryMode: 'Logs',
- logGroups: [{ arn: logGroupNamesVariable.name, name: logGroupNamesVariable.name }],
+ logGroups: [{ arn: `$${logGroupNamesVariable.name}`, name: logGroupNamesVariable.name }],
hide: false,
id: '',
region: '$' + regionVariable.name,
@@ -237,6 +237,7 @@ describe('CloudWatchLogsQueryRunner', () => {
logsTimeout: '500ms',
},
},
+ mockGetVariableName: false,
});
const spy = jest.spyOn(runner, 'makeLogActionRequest');
await lastValueFrom(
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
index 108a620aa09..59b0d1a852c 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
@@ -85,13 +85,15 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
const startQueryRequests: StartQueryRequest[] = validLogQueries.map((target: CloudWatchLogsQuery) => {
const interpolatedLogGroupArns = interpolateStringArrayUsingSingleOrMultiValuedVariable(
this.templateSrv,
- (target.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn)
+ (target.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn),
+ options.scopedVars
);
// need to support legacy format variables too
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
this.templateSrv,
target.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
+ options.scopedVars,
'text'
);
diff --git a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.test.ts b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.test.ts
index f65ea9b2653..986cff080e3 100644
--- a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.test.ts
@@ -11,6 +11,8 @@ interface TestCase {
key?: 'value' | 'text';
}
+const suffix = 'suffix';
+
describe('templateVariableUtils', () => {
const multiValuedRepresentedAsArray = {
...logGroupNamesVariable,
@@ -70,14 +72,57 @@ describe('templateVariableUtils', () => {
test.each(cases)('$name', async ({ variable, expected, key }) => {
const templateService = setupMockedTemplateService([variable]);
const strings = ['$' + variable.name, 'log-group'];
- const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings, key);
+ const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings, {}, key);
+ expect(result).toEqual([...expected, 'log-group']);
+ });
+
+ const casesWithMultipleVariablesInString: TestCase[] = [
+ {
+ name: 'string with multiple variables should expand multi-valued variable with two values and use the metric find values',
+ variable: logGroupNamesVariable,
+ expected: [`${regionVariable.current.text}-${[...logGroupNamesVariable.current.value].join('|')}-${suffix}`],
+ },
+ {
+ name: 'string with multiple variables should expand multi-valued variable with two values and use the metric find texts',
+ variable: logGroupNamesVariable,
+ expected: [`${regionVariable.current.text}-${[...logGroupNamesVariable.current.text].join(' + ')}-${suffix}`],
+ key: 'text',
+ },
+ {
+ name: 'string with multiple variables should expand multi-valued variable with one selected value represented as array and use metric find values',
+ variable: multiValuedRepresentedAsArray,
+ expected: [`${regionVariable.current.text}-${multiValuedRepresentedAsArray.current.value}-${suffix}`],
+ },
+ {
+ name: 'should expand multi-valued variable with one selected value represented as array and use metric find texts',
+ variable: multiValuedRepresentedAsArray,
+ expected: [`${regionVariable.current.text}-${multiValuedRepresentedAsArray.current.text}-${suffix}`],
+ key: 'text',
+ },
+ {
+ name: 'string with multiple variables should expand multi-valued variable with one selected value represented as a string and use metric find value',
+ variable: multiValuedRepresentedAsString,
+ expected: [`${regionVariable.current.text}-${multiValuedRepresentedAsString.current.value}-${suffix}`],
+ },
+ {
+ name: 'string with multiple variables should expand multi-valued variable with one selected value represented as a string and use metric find text',
+ variable: multiValuedRepresentedAsString,
+ expected: [`${regionVariable.current.text}-${multiValuedRepresentedAsString.current.text}-${suffix}`],
+ key: 'text',
+ },
+ ];
+
+ test.each(casesWithMultipleVariablesInString)('$name', async ({ variable, expected, key }) => {
+ const templateService = setupMockedTemplateService([regionVariable, variable]);
+ const strings = [`$${regionVariable.name}-$${variable.name}-${suffix}`, 'log-group'];
+ const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings, {}, key);
expect(result).toEqual([...expected, 'log-group']);
});
it('should expand single-valued variable', () => {
const templateService = setupMockedTemplateService([regionVariable]);
const strings = ['$' + regionVariable.name, 'us-east-2'];
- const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings);
+ const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings, {});
expect(result).toEqual([regionVariable.current.value, 'us-east-2']);
});
});
diff --git a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts
index c537d5f63b8..bf5458a793c 100644
--- a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts
+++ b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts
@@ -1,4 +1,4 @@
-import { VariableOption, UserProps, OrgProps, DashboardProps } from '@grafana/data';
+import { VariableOption, UserProps, OrgProps, DashboardProps, ScopedVars } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
/**
@@ -9,14 +9,17 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
* multi-valued variable + non-variable item. ['$multiValuedVariable', 'log-group'] => ['value1', 'value2', 'log-group']
* @param templateSrv - The template service
* @param strings - The array of strings to interpolate. May contain variables and non-variables.
+ * @pararm scopedVars - The scoped variables to use when interpolating the variables.
* @param key - Allows you to specify whether the variable MetricFindValue.text or MetricFindValue.value should be used when interpolating the variable. Optional, defaults to 'value'.
**/
export const interpolateStringArrayUsingSingleOrMultiValuedVariable = (
templateSrv: TemplateSrv,
strings: string[],
+ scopedVars: ScopedVars,
key?: 'value' | 'text'
) => {
key = key ?? 'value';
+ const format = key === 'value' ? 'pipe' : 'text';
let result: string[] = [];
for (const string of strings) {
const variableName = templateSrv.getVariableName(string);
@@ -25,9 +28,10 @@ export const interpolateStringArrayUsingSingleOrMultiValuedVariable = (
if (valueVar && 'current' in valueVar && isVariableOption(valueVar.current)) {
const rawValue = valueVar.current[key];
if (Array.isArray(rawValue)) {
- result = [...result, ...rawValue];
+ const separator = format === 'text' ? ' + ' : '|';
+ result.push(...templateSrv.replace(string, scopedVars, format).split(separator));
} else if (typeof rawValue === 'string') {
- result.push(rawValue);
+ result.push(templateSrv.replace(string, scopedVars, format));
}
} else {
// if it's not a variable, just add the raw value
diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts
index 3e27c5f87ba..637bf49a2bc 100644
--- a/public/app/plugins/datasource/loki/LanguageProvider.ts
+++ b/public/app/plugins/datasource/loki/LanguageProvider.ts
@@ -386,12 +386,6 @@ export default class LokiLanguageProvider extends LanguageProvider {
return [];
}
- async refreshLogLabels(forceRefresh?: boolean) {
- if ((this.labelKeys && Date.now().valueOf() - this.labelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
- await this.fetchLabels();
- }
- }
-
/**
* Fetch labels for a selector. This is cached by its args but also by the global timeRange currently selected as
* they can change over requested time.
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
index 9ff76b04b3f..0e92e3cd358 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
@@ -49,8 +49,7 @@ export const LokiQueryBuilder = React.memo(({ datasource, query, onChange
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
if (labelsToConsider.length === 0) {
- await datasource.languageProvider.refreshLogLabels();
- return datasource.languageProvider.getLabelKeys();
+ return await datasource.languageProvider.fetchLabels();
}
const expr = lokiQueryModeller.renderLabels(labelsToConsider);
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx
index 8a8b75fa032..33f459ad2cf 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx
@@ -452,6 +452,7 @@ export const MetricEncyclopediaModal = (props: Props) => {
} else {
// search either the names or all metadata
// fuzzy search go!
+
if (fullMetaSearch) {
debouncedFuzzySearch(metaHaystack, value, setFuzzyMetaSearchResults);
} else {
diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx
index 3b74a48a67c..bd40346e0f2 100644
--- a/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx
+++ b/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx
@@ -4,7 +4,7 @@ import React from 'react';
import { dateMath, dateTime, EventBus, LoadingState, TimeRange, toDataFrame, VizOrientation } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
-import { BarGaugeDisplayMode } from '@grafana/schema';
+import { BarGaugeDisplayMode, BarGaugeValueMode } from '@grafana/schema';
import { BarGaugePanel, BarGaugePanelProps } from './BarGaugePanel';
@@ -101,6 +101,7 @@ function buildPanelData(overrideValues?: Partial): BarGaugeP
showUnfilled: true,
minVizHeight: 10,
minVizWidth: 0,
+ valueMode: BarGaugeValueMode.Color,
},
transparent: false,
timeRange,
diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx
index 0165cf3677f..326e5b3fb60 100644
--- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx
+++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx
@@ -49,6 +49,7 @@ export class BarGaugePanel extends PureComponent {
className={targetClassName}
alignmentFactors={count > 1 ? alignmentFactors : undefined}
showUnfilled={options.showUnfilled}
+ valueDisplayMode={options.valueMode}
/>
);
};
diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx
index bfe41e90853..778abfd49ab 100644
--- a/public/app/plugins/panel/bargauge/module.tsx
+++ b/public/app/plugins/panel/bargauge/module.tsx
@@ -1,5 +1,5 @@
import { PanelPlugin, VizOrientation } from '@grafana/data';
-import { BarGaugeDisplayMode } from '@grafana/schema';
+import { BarGaugeDisplayMode, BarGaugeValueMode } from '@grafana/schema';
import { commonOptionsBuilder, sharedSingleStatPanelChangedHandler } from '@grafana/ui';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common';
@@ -29,6 +29,18 @@ export const plugin = new PanelPlugin(BarGaugePanel)
},
defaultValue: defaultPanelOptions.displayMode,
})
+ .addRadio({
+ path: 'valueMode',
+ name: 'Value display',
+ settings: {
+ options: [
+ { value: BarGaugeValueMode.Color, label: 'Value color' },
+ { value: BarGaugeValueMode.Text, label: 'Text color' },
+ { value: BarGaugeValueMode.Hidden, label: 'Hidden' },
+ ],
+ },
+ defaultValue: defaultPanelOptions.valueMode,
+ })
.addBooleanSwitch({
path: 'showUnfilled',
name: 'Show unfilled area',
diff --git a/public/app/plugins/panel/bargauge/panelcfg.cue b/public/app/plugins/panel/bargauge/panelcfg.cue
index 73f133fb39c..ffb83db0724 100644
--- a/public/app/plugins/panel/bargauge/panelcfg.cue
+++ b/public/app/plugins/panel/bargauge/panelcfg.cue
@@ -29,6 +29,7 @@ composableKinds: PanelCfg: {
PanelOptions: {
common.SingleStatBaseOptions
displayMode: common.BarGaugeDisplayMode | *"gradient"
+ valueMode: common.BarGaugeValueMode | *"color"
showUnfilled: bool | *true
minVizWidth: uint32 | *0
minVizHeight: uint32 | *10
diff --git a/public/app/plugins/panel/bargauge/panelcfg.gen.ts b/public/app/plugins/panel/bargauge/panelcfg.gen.ts
index f42c6814cfc..9d2b6b5135b 100644
--- a/public/app/plugins/panel/bargauge/panelcfg.gen.ts
+++ b/public/app/plugins/panel/bargauge/panelcfg.gen.ts
@@ -17,6 +17,7 @@ export interface PanelOptions extends common.SingleStatBaseOptions {
minVizHeight: number;
minVizWidth: number;
showUnfilled: boolean;
+ valueMode: common.BarGaugeValueMode;
}
export const defaultPanelOptions: Partial = {
@@ -24,4 +25,5 @@ export const defaultPanelOptions: Partial = {
minVizHeight: 10,
minVizWidth: 0,
showUnfilled: true,
+ valueMode: common.BarGaugeValueMode.Color,
};
diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx
index 22d150e2463..c72381de55a 100644
--- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx
+++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx
@@ -92,8 +92,12 @@ const FlameGraph = ({
const foundLabels = new Set();
if (search) {
- for (let idx of ufuzzy.filter(uniqueLabels, search)) {
- foundLabels.add(uniqueLabels[idx]);
+ let idxs = ufuzzy.filter(uniqueLabels, search);
+
+ if (idxs) {
+ for (let idx of idxs) {
+ foundLabels.add(uniqueLabels[idx]);
+ }
}
}
diff --git a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx
index 4b75ba35a2a..b3ac0f1cbf8 100644
--- a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx
+++ b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx
@@ -45,7 +45,7 @@ const FlameGraphTopTableContainer = ({
let label, self, value;
let table: { [key: string]: TableData } = {};
- if (data.fields.length === 6) {
+ if (data.fields.length > 3) {
const valueValues = data.fields[1].values;
const selfValues = data.fields[2].values;
const labelValues = data.fields[3].values;
diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx
index c1bf6854791..8de4e823063 100644
--- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx
+++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx
@@ -1,12 +1,15 @@
+import { css } from '@emotion/css';
import { merge } from 'lodash';
import React, { useState } from 'react';
-import { SelectableValue } from '@grafana/data';
+import { GrafanaTheme2, SelectableValue } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { TableCellOptions } from '@grafana/schema';
-import { Field, Select, TableCellDisplayMode } from '@grafana/ui';
+import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
+import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
// The props that any cell type editor are expected
// to handle. In this case the generic type should
@@ -23,8 +26,8 @@ interface Props {
export const TableCellOptionEditor = ({ value, onChange }: Props) => {
const cellType = value.type;
+ const styles = useStyles2(getStyles);
const currentMode = cellDisplayModeOptions.find((o) => o.value!.type === cellType)!;
-
let [settingCache, setSettingCache] = useState>({});
// Update display mode on change
@@ -54,7 +57,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
// Setup and inject editor
return (
- <>
+
@@ -64,15 +67,30 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{cellType === TableCellDisplayMode.ColorBackground && (
)}
- >
+ {cellType === TableCellDisplayMode.Sparkline && (
+
+ )}
+
);
};
+const SparklineDisplayModeOption: SelectableValue = {
+ value: { type: TableCellDisplayMode.Sparkline },
+ label: 'Sparkline',
+};
+
const cellDisplayModeOptions: Array> = [
{ value: { type: TableCellDisplayMode.Auto }, label: 'Auto' },
+ ...(config.featureToggles.timeSeriesTable ? [SparklineDisplayModeOption] : []),
{ value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' },
{ value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' },
{ value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' },
{ value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' },
{ value: { type: TableCellDisplayMode.Image }, label: 'Image' },
];
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ fixBottomMargin: css({
+ marginBottom: theme.spacing(-2),
+ }),
+});
diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx
index 3ae842d3d59..485024b55d9 100644
--- a/public/app/plugins/panel/table/TablePanel.tsx
+++ b/public/app/plugins/panel/table/TablePanel.tsx
@@ -56,6 +56,7 @@ export function TablePanel(props: Props) {
footerOptions={options.footer}
enablePagination={options.footer?.enablePagination}
subData={subData}
+ cellHeight={options.cellHeight}
/>
);
diff --git a/public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx
index e8a3d04f908..6465e7284c6 100644
--- a/public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx
+++ b/public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx
@@ -1,32 +1,46 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
-import { BarGaugeDisplayMode, TableBarGaugeCellOptions } from '@grafana/schema';
-import { Field, HorizontalGroup, Select } from '@grafana/ui';
+import { Stack } from '@grafana/experimental';
+import { BarGaugeDisplayMode, BarGaugeValueMode, TableBarGaugeCellOptions } from '@grafana/schema';
+import { Field, RadioButtonGroup, Select } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
+type Props = TableCellEditorProps;
+
+export function BarGaugeCellOptionsEditor({ cellOptions, onChange }: Props) {
+ // Set the display mode on change
+ const onCellOptionsChange = (v: SelectableValue) => {
+ cellOptions.mode = v.value;
+ onChange(cellOptions);
+ };
+
+ const onValueModeChange = (v: BarGaugeValueMode) => {
+ cellOptions.valueDisplayMode = v;
+ onChange(cellOptions);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
const barGaugeOpts: SelectableValue[] = [
{ value: BarGaugeDisplayMode.Basic, label: 'Basic' },
{ value: BarGaugeDisplayMode.Gradient, label: 'Gradient' },
{ value: BarGaugeDisplayMode.Lcd, label: 'Retro LCD' },
];
-export const BarGaugeCellOptionsEditor = ({
- cellOptions,
- onChange,
-}: TableCellEditorProps) => {
- // Set the display mode on change
- const onCellOptionsChange = (v: SelectableValue) => {
- cellOptions.mode = v.value;
- onChange(cellOptions);
- };
-
- return (
-
-
-
-
-
- );
-};
+const valueModes: SelectableValue[] = [
+ { value: BarGaugeValueMode.Color, label: 'Value color' },
+ { value: BarGaugeValueMode.Text, label: 'Text color' },
+ { value: BarGaugeValueMode.Hidden, label: 'Hidden' },
+];
diff --git a/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
index 7650d51ec78..1b794902bdf 100644
--- a/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
+++ b/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { SelectableValue } from '@grafana/data';
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
-import { HorizontalGroup, Select, Field } from '@grafana/ui';
+import { Select, Field } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
@@ -22,10 +22,8 @@ export const ColorBackgroundCellOptionsEditor = ({
};
return (
-
-
-
-
-
+
+
+
);
};
diff --git a/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
new file mode 100644
index 00000000000..2e7ade9d591
--- /dev/null
+++ b/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
@@ -0,0 +1,79 @@
+import { css } from '@emotion/css';
+import React, { useMemo } from 'react';
+
+import { createFieldConfigRegistry } from '@grafana/data';
+import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
+import { VerticalGroup, Field, useStyles2 } from '@grafana/ui';
+import { defaultSparklineCellConfig } from '@grafana/ui/src/components/Table/SparklineCell';
+
+import { getGraphFieldConfig } from '../../timeseries/config';
+import { TableCellEditorProps } from '../TableCellOptionEditor';
+
+type OptionKey = keyof TableSparklineCellOptions;
+
+const optionIds: Array = [
+ 'drawStyle',
+ 'lineInterpolation',
+ 'barAlignment',
+ 'lineWidth',
+ 'fillOpacity',
+ 'gradientMode',
+ 'lineStyle',
+ 'spanNulls',
+ 'showPoints',
+ 'pointSize',
+];
+
+export const SparklineCellOptionsEditor = (props: TableCellEditorProps) => {
+ const { cellOptions, onChange } = props;
+
+ const registry = useMemo(() => {
+ const config = getGraphFieldConfig(defaultSparklineCellConfig);
+ return createFieldConfigRegistry(config, 'ChartCell');
+ }, []);
+
+ const style = useStyles2(getStyles);
+
+ const values = { ...defaultSparklineCellConfig, ...cellOptions };
+
+ return (
+
+ {registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
+ if (item.showIf && !item.showIf(values)) {
+ return null;
+ }
+ const Editor = item.editor;
+ const path = item.path;
+
+ return (
+
+ onChange({ ...cellOptions, [path]: val })}
+ value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue}
+ item={item}
+ context={{ data: [] }}
+ />
+
+ );
+ })}
+
+ );
+};
+
+// jumping through hoops to avoid using "any"
+function isOptionKey(key: string, options: TableSparklineCellOptions): key is OptionKey {
+ return key in options;
+}
+
+const getStyles = () => ({
+ field: css`
+ width: 100%;
+
+ // @TODO don't show "scheme" option for custom gradient mode.
+ // it needs thresholds to work, which are not supported
+ // for area chart cell right now
+ [title='Use color scheme to define gradient'] {
+ display: none;
+ }
+ `,
+});
diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx
index 9604f5c2df7..9b90878c2a1 100644
--- a/public/app/plugins/panel/table/module.tsx
+++ b/public/app/plugins/panel/table/module.tsx
@@ -7,7 +7,13 @@ import {
standardEditorsRegistry,
identityOverrideProcessor,
} from '@grafana/data';
-import { TableFieldOptions, TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions } from '@grafana/schema';
+import {
+ TableFieldOptions,
+ TableCellOptions,
+ TableCellDisplayMode,
+ defaultTableFieldOptions,
+ TableCellHeight,
+} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor';
@@ -17,7 +23,7 @@ import { PanelOptions, defaultPanelOptions } from './panelcfg.gen';
import { TableSuggestionsSupplier } from './suggestions';
const footerCategory = 'Table footer';
-const cellCategory = ['Cell Options'];
+const cellCategory = ['Cell options'];
export const plugin = new PanelPlugin(TablePanel)
.setPanelChangeHandler(tablePanelChangedHandler)
@@ -64,7 +70,7 @@ export const plugin = new PanelPlugin(TablePane
.addCustomEditor({
id: 'cellOptions',
path: 'cellOptions',
- name: 'Cell Type',
+ name: 'Cell type',
editor: TableCellOptionEditor,
override: TableCellOptionEditor,
defaultValue: defaultTableFieldOptions.cellOptions,
@@ -108,6 +114,18 @@ export const plugin = new PanelPlugin(TablePane
name: 'Show table header',
defaultValue: defaultPanelOptions.showHeader,
})
+ .addRadio({
+ path: 'cellHeight',
+ name: 'Cell height',
+ defaultValue: defaultPanelOptions.cellHeight,
+ settings: {
+ options: [
+ { value: TableCellHeight.Sm, label: 'Small' },
+ { value: TableCellHeight.Md, label: 'Medium' },
+ { value: TableCellHeight.Lg, label: 'Large' },
+ ],
+ },
+ })
.addBooleanSwitch({
path: 'showRowNums',
name: 'Show row numbers',
diff --git a/public/app/plugins/panel/table/panelcfg.cue b/public/app/plugins/panel/table/panelcfg.cue
index a574c08d6b2..563561e380d 100644
--- a/public/app/plugins/panel/table/panelcfg.cue
+++ b/public/app/plugins/panel/table/panelcfg.cue
@@ -45,6 +45,8 @@ composableKinds: PanelCfg: {
// Represents the selected calculations
reducer: []
}
+ // Controls the height of the rows
+ cellHeight?: ui.TableCellHeight | *"md"
} @cuetsy(kind="interface")
},
]
diff --git a/public/app/plugins/panel/table/panelcfg.gen.ts b/public/app/plugins/panel/table/panelcfg.gen.ts
index c0533fcb87a..85d617bc4ba 100644
--- a/public/app/plugins/panel/table/panelcfg.gen.ts
+++ b/public/app/plugins/panel/table/panelcfg.gen.ts
@@ -13,6 +13,10 @@ import * as ui from '@grafana/schema';
export const PanelCfgModelVersion = Object.freeze([0, 0]);
export interface PanelOptions {
+ /**
+ * Controls the height of the rows
+ */
+ cellHeight?: ui.TableCellHeight;
/**
* Controls footer options
*/
@@ -40,6 +44,7 @@ export interface PanelOptions {
}
export const defaultPanelOptions: Partial = {
+ cellHeight: ui.TableCellHeight.Md,
footer: {
/**
* Controls whether the footer should be shown
diff --git a/yarn.lock b/yarn.lock
index 97eb18e03a8..979809e6f2d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4956,7 +4956,7 @@ __metadata:
react-test-renderer: 17.0.2
react-use: 17.4.0
regenerator-runtime: 0.13.11
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -4983,7 +4983,7 @@ __metadata:
"@rollup/plugin-node-resolve": 15.0.1
"@types/node": 18.14.6
esbuild: 0.16.17
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -4993,14 +4993,14 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/e2e-selectors@npm:canary":
- version: 9.4.0-96193pre
- resolution: "@grafana/e2e-selectors@npm:9.4.0-96193pre"
+"@grafana/e2e-selectors@npm:^9.4.3":
+ version: 9.4.3
+ resolution: "@grafana/e2e-selectors@npm:9.4.3"
dependencies:
"@grafana/tsconfig": ^1.2.0-rc1
tslib: 2.4.1
typescript: 4.8.4
- checksum: 1ce663607c0c816ec02cad33d80c38731c4dfafdc7d1693baa989b8eff6588e3cc1d8a031c4f30f888bbd8aa8768cce7c5dd4cb2455306040f39dcac933ceae4
+ checksum: 85a88cdf4adb643ff863b15f96fc6c04ecb7567c27cc526a00c157eb02575e55adc1e7701d58e1b48f00f24951c332fbb191cd2b5a8e74cd0c545543777e82af
languageName: node
linkType: hard
@@ -5031,7 +5031,7 @@ __metadata:
lodash: 4.17.21
mocha: 10.2.0
resolve-bin: 1.0.1
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -5041,7 +5041,7 @@ __metadata:
tslib: 2.5.0
typescript: 4.8.4
uuid: 9.0.0
- webpack: 5.75.0
+ webpack: 5.76.0
yaml: ^2.0.0
bin:
grafana-e2e: bin/grafana-e2e.js
@@ -5190,7 +5190,7 @@ __metadata:
lodash: 4.17.21
react: 17.0.2
react-dom: 17.0.2
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -5207,17 +5207,17 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/scenes@npm:^0.0.16":
- version: 0.0.16
- resolution: "@grafana/scenes@npm:0.0.16"
+"@grafana/scenes@npm:^0.0.18":
+ version: 0.0.18
+ resolution: "@grafana/scenes@npm:0.0.18"
dependencies:
- "@grafana/e2e-selectors": canary
+ "@grafana/e2e-selectors": ^9.4.3
"@grafana/experimental": 1.0.1
react-grid-layout: 1.3.4
react-use: 17.4.0
react-virtualized-auto-sizer: 1.0.7
uuid: ^9.0.0
- checksum: c6cf9f1571309da4ed0fb5046f8abf18b324e9847890c54307ca093610760d52fdb44002fb1db8e81abca6371121a0623a2f44c9828ef80b11d8d189993f457a
+ checksum: f863cbf410d71e579cd4e343fb7b8ad014bc66a250f4d31f9d43c43fe8e97f5b6e3e8d01ed69818c347b3db31b6ecb153d1dc7a0e2946d360e7e1806eb6f5d8c
languageName: node
linkType: hard
@@ -5230,7 +5230,7 @@ __metadata:
"@rollup/plugin-json": 5.0.1
"@rollup/plugin-node-resolve": 15.0.1
esbuild: 0.16.17
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -5351,7 +5351,7 @@ __metadata:
"@grafana/e2e-selectors": 9.5.0-pre
"@grafana/schema": 9.5.0-pre
"@grafana/tsconfig": ^1.2.0-rc1
- "@leeoniya/ufuzzy": 1.0.2
+ "@leeoniya/ufuzzy": 1.0.6
"@mdx-js/react": 1.6.22
"@monaco-editor/react": 4.4.6
"@popperjs/core": 2.11.6
@@ -5462,7 +5462,7 @@ __metadata:
react-transition-group: 4.4.5
react-use: 17.4.0
react-window: 1.8.8
- rimraf: 4.2.0
+ rimraf: 4.4.0
rollup: 2.79.1
rollup-plugin-dts: ^5.0.0
rollup-plugin-esbuild: 5.0.0
@@ -5481,7 +5481,7 @@ __metadata:
typescript: 4.8.4
uplot: 1.6.24
uuid: 9.0.0
- webpack: 5.75.0
+ webpack: 5.76.0
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
@@ -6330,10 +6330,10 @@ __metadata:
languageName: node
linkType: hard
-"@leeoniya/ufuzzy@npm:1.0.2":
- version: 1.0.2
- resolution: "@leeoniya/ufuzzy@npm:1.0.2"
- checksum: 5460378a8c32d121b0bc7c8e95cde995316516655528e248051b1bf360cdca0311ef3275de14b802587748231333cee6183c931b3abba26f9e4236ecc4959aa3
+"@leeoniya/ufuzzy@npm:1.0.6":
+ version: 1.0.6
+ resolution: "@leeoniya/ufuzzy@npm:1.0.6"
+ checksum: e09672848e094745726331feebe6744d42563ccf160b38796eed4d72105daa052838fdec1944d5b44a27e328fc74a00547d00b894710e2720aa692ed5d3d6e3b
languageName: node
linkType: hard
@@ -21888,15 +21888,15 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:9.1.2":
- version: 9.1.2
- resolution: "glob@npm:9.1.2"
+"glob@npm:9.2.1, glob@npm:^9.2.0":
+ version: 9.2.1
+ resolution: "glob@npm:9.2.1"
dependencies:
fs.realpath: ^1.0.0
minimatch: ^7.4.1
minipass: ^4.2.4
- path-scurry: ^1.5.0
- checksum: 8c0bcd729b5783c340553966a25728a6c6e53275b825b4bf3be52bc2d6d31ea39d55d3c7e989edfc541f5a0ba7421b8616788f55367dd8551aac6cebbf7491ab
+ path-scurry: ^1.6.1
+ checksum: ef9b1c32491e6b532bdd0d2abcc3c9f48e83446609e11285869156982fc5a756dfbaa6f59f797712343bd1e22500ac15708692806633653fde4ef67c85e2aab7
languageName: node
linkType: hard
@@ -21913,18 +21913,6 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:^9.2.0":
- version: 9.2.1
- resolution: "glob@npm:9.2.1"
- dependencies:
- fs.realpath: ^1.0.0
- minimatch: ^7.4.1
- minipass: ^4.2.4
- path-scurry: ^1.6.1
- checksum: ef9b1c32491e6b532bdd0d2abcc3c9f48e83446609e11285869156982fc5a756dfbaa6f59f797712343bd1e22500ac15708692806633653fde4ef67c85e2aab7
- languageName: node
- linkType: hard
-
"global-dirs@npm:^3.0.0":
version: 3.0.1
resolution: "global-dirs@npm:3.0.1"
@@ -22130,13 +22118,13 @@ __metadata:
"@grafana/lezer-logql": 0.1.2
"@grafana/monaco-logql": ^0.0.7
"@grafana/runtime": "workspace:*"
- "@grafana/scenes": ^0.0.16
+ "@grafana/scenes": ^0.0.18
"@grafana/schema": "workspace:*"
"@grafana/toolkit": "workspace:*"
"@grafana/tsconfig": ^1.2.0-rc1
"@grafana/ui": "workspace:*"
"@kusto/monaco-kusto": 5.3.6
- "@leeoniya/ufuzzy": 1.0.2
+ "@leeoniya/ufuzzy": 1.0.6
"@lezer/common": 1.0.2
"@lezer/highlight": 1.1.3
"@lezer/lr": 1.3.3
@@ -22288,7 +22276,7 @@ __metadata:
file-saver: 2.0.5
fork-ts-checker-webpack-plugin: 8.0.0
framework-utils: ^1.1.0
- glob: 9.1.2
+ glob: 9.2.1
history: 4.10.1
hoist-non-react-statics: 3.3.2
html-loader: 4.2.0
@@ -22387,7 +22375,7 @@ __metadata:
redux-thunk: 2.4.2
regenerator-runtime: 0.13.11
reselect: 4.1.7
- rimraf: 4.2.0
+ rimraf: 4.4.0
rst2html: "github:thoward/rst2html#990cb89f2a300cdd9151790be377c4c0840df809"
rudder-sdk-js: 2.25.0
rxjs: 7.8.0
@@ -22421,7 +22409,7 @@ __metadata:
vendor: "link:./public/vendor"
visjs-network: 4.25.0
wait-on: 7.0.1
- webpack: 5.75.0
+ webpack: 5.76.0
webpack-bundle-analyzer: 4.8.0
webpack-cli: 5.0.1
webpack-dev-server: 4.11.1
@@ -30356,7 +30344,7 @@ __metadata:
languageName: node
linkType: hard
-"path-scurry@npm:^1.5.0, path-scurry@npm:^1.6.1":
+"path-scurry@npm:^1.6.1":
version: 1.6.1
resolution: "path-scurry@npm:1.6.1"
dependencies:
@@ -34600,14 +34588,14 @@ __metadata:
languageName: node
linkType: hard
-"rimraf@npm:4.2.0":
- version: 4.2.0
- resolution: "rimraf@npm:4.2.0"
+"rimraf@npm:4.4.0":
+ version: 4.4.0
+ resolution: "rimraf@npm:4.4.0"
dependencies:
glob: ^9.2.0
bin:
rimraf: dist/cjs/src/bin.js
- checksum: 885baaec6d8e3a771bb3060f110c29070073a47fdcb26775ae165703173e79e845974dc8ed07e8eb4c3c85f126777968febdb385d0c274ad3b167db326e7f82e
+ checksum: 0cedaf9d138589d1bb0ab851f05804c6d30827aa66563472b04ab76245f83537e23e7b94f1f79ea6c368c0d84a18fcde6a756fca3a44c967e08792671b3a0a6e
languageName: node
linkType: hard
@@ -39484,9 +39472,9 @@ __metadata:
languageName: node
linkType: hard
-"webpack@npm:5.75.0":
- version: 5.75.0
- resolution: "webpack@npm:5.75.0"
+"webpack@npm:5.76.0":
+ version: 5.76.0
+ resolution: "webpack@npm:5.76.0"
dependencies:
"@types/eslint-scope": ^3.7.3
"@types/estree": ^0.0.51
@@ -39517,7 +39505,7 @@ __metadata:
optional: true
bin:
webpack: bin/webpack.js
- checksum: 2bcc5f3c195f375944e8af2f00bf2feea39cb9fda5f763b0d1b00077f1c51783db25c94d3fae96a07dead9fa085e6ae7474417e5ab31719c9776ea5969ceb83a
+ checksum: e897b3b34068222fe910d8894837cbf39f5e66b4772fe8d24dfc0090cd13e19337a3a757a97bc185be6c64d9d5a7b1ae372821b65a6079ce8c7ceba606edb409
languageName: node
linkType: hard